From 0e745500c335b5a2b46d5488faca97d1aa0aed2e Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Sat, 30 May 2026 12:49:27 -0700 Subject: [PATCH 01/16] feat(coding-agent): add Codex fast mode Add persisted chat/workflow Codex fast-mode toggles, conditional /fast UI, and OpenAI priority service-tier wiring for supported providers. Refs #1134 AI-Assisted-By: Codex --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/docs/providers.md | 4 + packages/coding-agent/docs/settings.md | 18 + packages/coding-agent/docs/usage.md | 1 + .../coding-agent/src/core/codex-fast-mode.ts | 79 +++ packages/coding-agent/src/core/sdk.ts | 57 +- .../coding-agent/src/core/settings-manager.ts | 24 + .../coding-agent/src/core/slash-commands.ts | 1 + .../components/fast-mode-selector.ts | 112 ++++ .../src/modes/interactive/components/index.ts | 1 + .../src/modes/interactive/interactive-mode.ts | 68 ++- .../coding-agent/test/codex-fast-mode.test.ts | 70 +++ .../test/fast-mode-selector.test.ts | 77 +++ .../test/interactive-mode-status.test.ts | 65 +++ .../test/sdk-codex-fast-mode.test.ts | 195 +++++++ .../settings-manager-codex-fast-mode.test.ts | 58 ++ ...lora131-atomic-issues-1134-in-this-repo.md | 532 ++++++++++++++++++ 17 files changed, 1342 insertions(+), 24 deletions(-) create mode 100644 packages/coding-agent/src/core/codex-fast-mode.ts create mode 100644 packages/coding-agent/src/modes/interactive/components/fast-mode-selector.ts create mode 100644 packages/coding-agent/test/codex-fast-mode.test.ts create mode 100644 packages/coding-agent/test/fast-mode-selector.test.ts create mode 100644 packages/coding-agent/test/sdk-codex-fast-mode.test.ts create mode 100644 packages/coding-agent/test/settings-manager-codex-fast-mode.test.ts create mode 100644 specs/2026-05-30-implement-github-issue-https-github-com-flora131-atomic-issues-1134-in-this-repo.md diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0225b8e84..075e14f5d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `/fast` Codex fast mode toggles for chat and workflow-stage sessions, applying OpenAI priority service tier to supported `openai/*` and `openai-codex/*` models only ([#1134](https://github.com/flora131/atomic/issues/1134)). + ## [0.8.21] - 2026-05-30 ### Changed diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index 538b9798c..98364cc4d 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -26,6 +26,10 @@ Use `/logout` to clear credentials. Tokens are stored in `~/.atomic/agent/auth.j - Requires ChatGPT Plus or Pro subscription - Officially endorsed by OpenAI: [Codex for OSS](https://developers.openai.com/community/codex-for-oss) +### Codex Fast Mode + +Run `/fast` in interactive mode to enable OpenAI priority service tier separately for normal chat and workflow-stage sessions. The command is shown only when the current model scope includes a supported `openai/*` or `openai-codex/*` model. Fast mode intentionally does not apply to `github-copilot/*`, Azure OpenAI, OpenRouter, or custom OpenAI-compatible providers. + ### Claude Pro/Max Anthropic subscription auth is active for Claude Pro/Max accounts. Third-party harness usage draws from [extra usage](https://claude.ai/settings/usage) and is billed per token, not against Claude plan limits. diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 041d204e9..0563b42db 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -34,6 +34,24 @@ Edit directly or use `/settings` for common options. Atomic reads legacy `~/.pi/ } ``` +### Codex Fast Mode + +Use `/fast` in interactive mode to edit these settings. Atomic applies fast mode only to supported `openai/*` and `openai-codex/*` providers, not `github-copilot/*` or other OpenAI-compatible providers. + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `codexFastMode.chat` | boolean | `false` | Use OpenAI priority service tier for supported normal chat requests | +| `codexFastMode.workflow` | boolean | `false` | Use OpenAI priority service tier for supported workflow-stage requests | + +```json +{ + "codexFastMode": { + "chat": true, + "workflow": false + } +} +``` + ### UI & Display | Setting | Type | Default | Description | diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index a1057ea12..cd6eff48a 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -38,6 +38,7 @@ Type `/` in the editor to open command completion. Extensions can register custo | `/login`, `/logout` | Manage OAuth or API-key credentials | | `/model` | Switch models | | `/scoped-models` | Enable/disable models for CTRL+P cycling | +| `/fast` | Toggle Codex fast mode for chat and workflow stages when `openai/*` or `openai-codex/*` models are available | | `/settings` | Thinking level, theme, message delivery, transport | | `/resume` | Pick from previous sessions | | `/new` | Start a new session | diff --git a/packages/coding-agent/src/core/codex-fast-mode.ts b/packages/coding-agent/src/core/codex-fast-mode.ts new file mode 100644 index 000000000..d8242df24 --- /dev/null +++ b/packages/coding-agent/src/core/codex-fast-mode.ts @@ -0,0 +1,79 @@ +import type { Api, Model, SimpleStreamOptions } from "@earendil-works/pi-ai"; +import type { OrchestrationContext } from "./extensions/index.ts"; + +export const CODEX_FAST_MODE_SERVICE_TIER = "priority" as const; + +export interface CodexFastModeResolvedSettings { + chat: boolean; + workflow: boolean; +} + +export type CodexFastModeScope = "chat" | "workflow"; + +export interface CodexFastModeStreamOptions extends SimpleStreamOptions { + serviceTier?: typeof CODEX_FAST_MODE_SERVICE_TIER; +} + +export function isCodexFastModeSupportedProvider(provider: string): boolean { + return provider === "openai" || provider === "openai-codex"; +} + +export function isCodexFastModeSupportedModel(model: Pick, "provider">): boolean { + return isCodexFastModeSupportedProvider(model.provider); +} + +export function hasSupportedCodexFastModeModel(models: readonly Pick, "provider">[]): boolean { + return models.some(isCodexFastModeSupportedModel); +} + +export function isWorkflowStageOrchestrationContext(context: OrchestrationContext | undefined): boolean { + return context?.kind === "workflow-stage"; +} + +export function getCodexFastModeScope(context: OrchestrationContext | undefined): CodexFastModeScope { + return isWorkflowStageOrchestrationContext(context) ? "workflow" : "chat"; +} + +export function isCodexFastModeEnabledForSession( + settings: CodexFastModeResolvedSettings, + context: OrchestrationContext | undefined, +): boolean { + return settings[getCodexFastModeScope(context)]; +} + +export function shouldApplyCodexFastMode( + model: Pick, "provider">, + settings: CodexFastModeResolvedSettings, + context: OrchestrationContext | undefined, +): boolean { + return isCodexFastModeSupportedModel(model) && isCodexFastModeEnabledForSession(settings, context); +} + +export function withCodexFastModeStreamOptions( + options: SimpleStreamOptions | undefined, + enabled: boolean, +): CodexFastModeStreamOptions | undefined { + if (!enabled) { + return options; + } + + return { + ...(options ?? {}), + serviceTier: CODEX_FAST_MODE_SERVICE_TIER, + }; +} + +function isObjectPayload(payload: unknown): payload is Record { + return typeof payload === "object" && payload !== null && !Array.isArray(payload); +} + +export function withCodexFastModePayload(payload: unknown, enabled = true): unknown { + if (!enabled || !isObjectPayload(payload) || "service_tier" in payload) { + return payload; + } + + return { + ...payload, + service_tier: CODEX_FAST_MODE_SERVICE_TIER, + }; +} diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 2bfe04f26..1ee7450ac 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -16,6 +16,11 @@ import { resolvePath } from "../utils/paths.ts"; import { AgentSession } from "./agent-session.ts"; import { formatNoModelsAvailableMessage } from "./auth-guidance.ts"; import { AuthStorage } from "./auth-storage.ts"; +import { + shouldApplyCodexFastMode, + withCodexFastModePayload, + withCodexFastModeStreamOptions, +} from "./codex-fast-mode.ts"; import { DEFAULT_THINKING_LEVEL } from "./defaults.ts"; import type { ExtensionRunner, @@ -397,32 +402,50 @@ export async function createAgentSession( tools: [], }, convertToLlm: convertToLlmWithBlockImages, - streamFn: async (model, context, options) => { + streamFn: async (model, context, streamOptions) => { const auth = await modelRegistry.getApiKeyAndHeaders(model); if (!auth.ok) { throw new Error(auth.error); } const providerRetrySettings = settingsManager.getProviderRetrySettings(); - const attributionHeaders = getAttributionHeaders(model, settingsManager, options?.sessionId); - return streamSimple(model, context, { - ...options, - apiKey: auth.apiKey, - timeoutMs: options?.timeoutMs ?? providerRetrySettings.timeoutMs, - maxRetries: options?.maxRetries ?? providerRetrySettings.maxRetries, - maxRetryDelayMs: - options?.maxRetryDelayMs ?? providerRetrySettings.maxRetryDelayMs, - headers: - attributionHeaders || auth.headers || options?.headers - ? { ...attributionHeaders, ...auth.headers, ...options?.headers } - : undefined, - }); + const attributionHeaders = getAttributionHeaders(model, settingsManager, streamOptions?.sessionId); + const fastModeEnabled = shouldApplyCodexFastMode( + model, + settingsManager.getCodexFastModeSettings(), + options.orchestrationContext, + ); + return streamSimple( + model, + context, + withCodexFastModeStreamOptions( + { + ...streamOptions, + apiKey: auth.apiKey, + timeoutMs: streamOptions?.timeoutMs ?? providerRetrySettings.timeoutMs, + maxRetries: streamOptions?.maxRetries ?? providerRetrySettings.maxRetries, + maxRetryDelayMs: + streamOptions?.maxRetryDelayMs ?? providerRetrySettings.maxRetryDelayMs, + headers: + attributionHeaders || auth.headers || streamOptions?.headers + ? { ...attributionHeaders, ...auth.headers, ...streamOptions?.headers } + : undefined, + }, + fastModeEnabled, + ), + ); }, - onPayload: async (payload, _model) => { + onPayload: async (payload, model) => { + const fastModeEnabled = shouldApplyCodexFastMode( + model, + settingsManager.getCodexFastModeSettings(), + options.orchestrationContext, + ); + const guardedPayload = withCodexFastModePayload(payload, fastModeEnabled); const runner = extensionRunnerRef.current; if (!runner?.hasHandlers("before_provider_request")) { - return payload; + return guardedPayload; } - return runner.emitBeforeProviderRequest(payload); + return runner.emitBeforeProviderRequest(guardedPayload); }, onResponse: async (response, _model) => { const runner = extensionRunnerRef.current; diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index fa7e2ddb9..d576efad8 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -65,6 +65,11 @@ export interface WarningSettings { anthropicExtraUsage?: boolean; // default: true } +export interface CodexFastModeSettings { + chat?: boolean; // default: false + workflow?: boolean; // default: false +} + export type TransportSetting = Transport; /** @@ -120,6 +125,7 @@ export interface Settings { showHardwareCursor?: boolean; // Show terminal cursor while still positioning it for IME markdown?: MarkdownSettings; warnings?: WarningSettings; + codexFastMode?: CodexFastModeSettings; // OpenAI priority service tier toggles for chat/workflow sessionDir?: string; // Custom session storage directory (same format as --session-dir CLI flag) httpIdleTimeoutMs?: number; // HTTP header/body idle timeout in milliseconds; 0 disables it } @@ -1113,4 +1119,22 @@ export class SettingsManager { this.markModified("warnings"); this.save(); } + + getCodexFastModeSettings(): { chat: boolean; workflow: boolean } { + return { + chat: this.settings.codexFastMode?.chat ?? false, + workflow: this.settings.codexFastMode?.workflow ?? false, + }; + } + + setCodexFastModeSettings(settings: { chat: boolean; workflow: boolean }): void { + if (!this.globalSettings.codexFastMode) { + this.globalSettings.codexFastMode = {}; + } + this.globalSettings.codexFastMode.chat = settings.chat; + this.globalSettings.codexFastMode.workflow = settings.workflow; + this.markModified("codexFastMode", "chat"); + this.markModified("codexFastMode", "workflow"); + this.save(); + } } diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index a0d9e8d26..9b11ad0cc 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -28,6 +28,7 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ { name: "settings", description: "Open settings menu" }, { name: "model", description: "Select model (opens selector UI)" }, { name: "scoped-models", description: "Enable/disable models for ctrl+p cycling" }, + { name: "fast", description: "Configure Codex fast mode for chat and workflows" }, { name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" }, { name: "import", description: "Import and resume a session from a JSONL file" }, { name: "share", description: "Share session as a secret GitHub gist" }, diff --git a/packages/coding-agent/src/modes/interactive/components/fast-mode-selector.ts b/packages/coding-agent/src/modes/interactive/components/fast-mode-selector.ts new file mode 100644 index 000000000..cfc0c15b6 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/fast-mode-selector.ts @@ -0,0 +1,112 @@ +import { matchesKey, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui"; +import { theme } from "../theme/theme.ts"; + +export interface FastModeSelectorConfig { + chat: boolean; + workflow: boolean; +} + +export interface FastModeSelectorCallbacks { + onChange: (settings: FastModeSelectorConfig) => void; + onCancel: () => void | Promise; +} + +type FastModeRow = keyof FastModeSelectorConfig; + +const ROWS: readonly FastModeRow[] = ["chat", "workflow"]; + +export class FastModeSelectorComponent { + private selectedRowIndex = 0; + private state: FastModeSelectorConfig; + private readonly callbacks: FastModeSelectorCallbacks; + + constructor(config: FastModeSelectorConfig, callbacks: FastModeSelectorCallbacks) { + this.state = { ...config }; + this.callbacks = callbacks; + } + + invalidate(): void {} + + render(width: number): string[] { + const lines: string[] = [theme.bold(theme.fg("accent", "Codex fast mode")), ""]; + const description = + "Uses OpenAI priority service tier for supported openai/* and openai-codex/* models."; + for (const line of wrapTextWithAnsi(description, Math.max(20, width))) { + lines.push(theme.fg("muted", line)); + } + lines.push(""); + for (const row of ROWS) { + lines.push(this.renderRow(row, width)); + } + lines.push(""); + lines.push(truncateToWidth(theme.fg("dim", "tab row · ←/→ change · esc close"), width)); + return lines.map((line) => truncateToWidth(line, width)); + } + + handleInput(data: string): void { + if (matchesKey(data, "tab") || matchesKey(data, "down")) { + this.moveRow(1); + return; + } + if (matchesKey(data, "shift+tab") || matchesKey(data, "up")) { + this.moveRow(-1); + return; + } + if (matchesKey(data, "left")) { + this.setCurrentRow(false); + return; + } + if (matchesKey(data, "right")) { + this.setCurrentRow(true); + return; + } + if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { + void this.callbacks.onCancel(); + } + } + + getFocusedRow(): FastModeRow { + return ROWS[this.selectedRowIndex]!; + } + + getSettings(): FastModeSelectorConfig { + return { ...this.state }; + } + + private moveRow(delta: 1 | -1): void { + this.selectedRowIndex = (this.selectedRowIndex + delta + ROWS.length) % ROWS.length; + } + + private setCurrentRow(enabled: boolean): void { + const row = this.getFocusedRow(); + if (this.state[row] === enabled) { + return; + } + this.state = { ...this.state, [row]: enabled }; + this.callbacks.onChange({ ...this.state }); + } + + private renderRow(row: FastModeRow, width: number): string { + const selected = this.getFocusedRow() === row; + const prefix = selected ? theme.fg("accent", "› ") : " "; + const label = row.padEnd(8, " "); + const labelText = selected ? theme.bold(theme.fg("accent", label)) : theme.fg("text", label); + const enabledText = this.renderValue(row, true); + const disabledText = this.renderValue(row, false); + return truncateToWidth(`${prefix}${labelText} ${enabledText} ${disabledText}`, width); + } + + private renderValue(row: FastModeRow, enabled: boolean): string { + const value = enabled ? "enabled" : "disabled"; + const selected = this.getFocusedRow() === row; + const active = this.state[row] === enabled; + const text = active ? `[${value}]` : ` ${value} `; + if (selected && active) { + return theme.bold(theme.fg("accent", text)); + } + if (active) { + return theme.fg("text", text); + } + return theme.fg("dim", text); + } +} diff --git a/packages/coding-agent/src/modes/interactive/components/index.ts b/packages/coding-agent/src/modes/interactive/components/index.ts index da5530154..a8bb8e5f9 100644 --- a/packages/coding-agent/src/modes/interactive/components/index.ts +++ b/packages/coding-agent/src/modes/interactive/components/index.ts @@ -37,6 +37,7 @@ export { DynamicBorder } from "./dynamic-border.ts"; export { ExtensionEditorComponent } from "./extension-editor.ts"; export { ExtensionInputComponent } from "./extension-input.ts"; export { ExtensionSelectorComponent } from "./extension-selector.ts"; +export { FastModeSelectorComponent, type FastModeSelectorCallbacks, type FastModeSelectorConfig } from "./fast-mode-selector.ts"; export { FooterComponent, UsageMeterComponent } from "./footer.ts"; export { keyHint, keyText, rawKeyHint } from "./keybinding-hints.ts"; export { LoginDialogComponent } from "./login-dialog.ts"; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 345638c4f..e470f1fe5 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -95,6 +95,9 @@ import { findExactModelReferenceMatch, resolveModelScope, } from "../../core/model-resolver.ts"; +import { + hasSupportedCodexFastModeModel, +} from "../../core/codex-fast-mode.ts"; import { configureHttpDispatcher } from "../../core/http-dispatcher.ts"; import { DefaultPackageManager } from "../../core/package-manager.ts"; import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "../../core/provider-display-names.ts"; @@ -152,6 +155,7 @@ import { EarendilAnnouncementComponent } from "./components/earendil-announcemen import { ExtensionEditorComponent } from "./components/extension-editor.ts"; import { ExtensionInputComponent } from "./components/extension-input.ts"; import { ExtensionSelectorComponent } from "./components/extension-selector.ts"; +import { FastModeSelectorComponent } from "./components/fast-mode-selector.ts"; import { FooterComponent, UsageMeterComponent } from "./components/footer.ts"; import { formatKeyText, @@ -558,15 +562,27 @@ export class InteractiveMode { })); } + private getCodexFastModeCandidateModels(): Model[] { + return this.session.scopedModels.length > 0 + ? this.session.scopedModels.map((scoped) => scoped.model) + : this.session.modelRegistry.getAvailable(); + } + + private hasCodexFastModeSupportedModels(): boolean { + return hasSupportedCodexFastModeModel( + this.getCodexFastModeCandidateModels(), + ); + } + private createBaseAutocompleteProvider(): AutocompleteProvider { // Define commands for autocomplete - const slashCommands: SlashCommand[] = BUILTIN_SLASH_COMMANDS.map( - (command) => ({ - name: command.name, - description: command.description, - getArgumentCompletions: command.getArgumentCompletions, - }), - ); + const slashCommands: SlashCommand[] = BUILTIN_SLASH_COMMANDS.filter( + (command) => command.name !== "fast" || this.hasCodexFastModeSupportedModels(), + ).map((command) => ({ + name: command.name, + description: command.description, + getArgumentCompletions: command.getArgumentCompletions, + })); const modelCommand = slashCommands.find( (command) => command.name === "model", @@ -2999,6 +3015,11 @@ export class InteractiveMode { this.editor.setText(""); return; } + if (text === "/fast") { + this.editor.setText(""); + this.showFastModeSelector(); + return; + } if (text === "/scoped-models") { this.editor.setText(""); await this.showModelsSelector(); @@ -4432,6 +4453,36 @@ export class InteractiveMode { this.ui.requestRender(); } + private showFastModeSelector(): void { + if (!this.hasCodexFastModeSupportedModels()) { + this.showWarning( + "Codex fast mode requires an available openai/* or openai-codex/* model.", + ); + return; + } + + this.showSelector((done) => { + const selector = new FastModeSelectorComponent( + this.settingsManager.getCodexFastModeSettings(), + { + onChange: (settings) => { + this.settingsManager.setCodexFastModeSettings(settings); + void this.settingsManager.flush(); + this.showStatus( + `Codex fast mode: chat ${settings.chat ? "enabled" : "disabled"}, workflow ${settings.workflow ? "enabled" : "disabled"}`, + ); + }, + onCancel: async () => { + await this.settingsManager.flush(); + done(); + this.ui.requestRender(); + }, + }, + ); + return { component: selector, focus: selector }; + }); + } + private showSettingsSelector(): void { this.showSelector((done) => { const selector = new SettingsSelectorComponent( @@ -4781,6 +4832,7 @@ export class InteractiveMode { this.session.setScopedModels([]); } await this.updateAvailableProviderCount(); + this.setupAutocompleteProvider(); this.ui.requestRender(); }; @@ -5250,6 +5302,7 @@ export class InteractiveMode { this.session.modelRegistry.authStorage.logout(providerOption.id); this.session.modelRegistry.refresh(); await this.updateAvailableProviderCount(); + this.setupAutocompleteProvider(); const message = providerOption.authType === "oauth" ? `Logged out of ${providerOption.name}` @@ -5315,6 +5368,7 @@ export class InteractiveMode { } await this.updateAvailableProviderCount(); + this.setupAutocompleteProvider(); this.footer.invalidate(); this.updateEditorBorderColor(); if (selectedModel) { diff --git a/packages/coding-agent/test/codex-fast-mode.test.ts b/packages/coding-agent/test/codex-fast-mode.test.ts new file mode 100644 index 000000000..370e687c5 --- /dev/null +++ b/packages/coding-agent/test/codex-fast-mode.test.ts @@ -0,0 +1,70 @@ +import type { Api, Model } from "@earendil-works/pi-ai"; +import { describe, expect, it } from "vitest"; +import { + CODEX_FAST_MODE_SERVICE_TIER, + getCodexFastModeScope, + hasSupportedCodexFastModeModel, + isCodexFastModeEnabledForSession, + isCodexFastModeSupportedProvider, + withCodexFastModePayload, + withCodexFastModeStreamOptions, +} from "../src/core/codex-fast-mode.ts"; +import type { OrchestrationContext } from "../src/core/extensions/index.ts"; + +function model(provider: string): Pick, "provider"> { + return { provider }; +} + +const workflowContext: OrchestrationContext = { + kind: "workflow-stage", + workflowRunId: "run-1", + workflowStageId: "stage-1", + workflowStageName: "Stage 1", + constraints: { + disableWorkflowTool: true, + maxSubagentDepth: 0, + }, +}; + +describe("codex fast mode helpers", () => { + it("supports only OpenAI and OpenAI Codex providers", () => { + expect(isCodexFastModeSupportedProvider("openai")).toBe(true); + expect(isCodexFastModeSupportedProvider("openai-codex")).toBe(true); + expect(isCodexFastModeSupportedProvider("github-copilot")).toBe(false); + expect(isCodexFastModeSupportedProvider("azure-openai-responses")).toBe(false); + }); + + it("detects supported models from provider IDs", () => { + expect(hasSupportedCodexFastModeModel([model("github-copilot")])).toBe(false); + expect(hasSupportedCodexFastModeModel([model("github-copilot"), model("openai")])).toBe(true); + expect(hasSupportedCodexFastModeModel([model("openai-codex")])).toBe(true); + }); + + it("selects chat versus workflow scope from orchestration context", () => { + expect(getCodexFastModeScope(undefined)).toBe("chat"); + expect(getCodexFastModeScope(workflowContext)).toBe("workflow"); + expect(isCodexFastModeEnabledForSession({ chat: true, workflow: false }, undefined)).toBe(true); + expect(isCodexFastModeEnabledForSession({ chat: true, workflow: false }, workflowContext)).toBe(false); + expect(isCodexFastModeEnabledForSession({ chat: false, workflow: true }, workflowContext)).toBe(true); + }); + + it("adds serviceTier to stream options only when enabled", () => { + expect(withCodexFastModeStreamOptions(undefined, false)).toBeUndefined(); + expect(withCodexFastModeStreamOptions({ temperature: 0.2 }, false)).toEqual({ temperature: 0.2 }); + expect(withCodexFastModeStreamOptions({ temperature: 0.2 }, true)).toEqual({ + temperature: 0.2, + serviceTier: CODEX_FAST_MODE_SERVICE_TIER, + }); + }); + + it("adds service_tier to object payloads without overwriting existing values", () => { + expect(withCodexFastModePayload("not-object", true)).toBe("not-object"); + expect(withCodexFastModePayload(["array"], true)).toEqual(["array"]); + expect(withCodexFastModePayload({ model: "gpt" }, false)).toEqual({ model: "gpt" }); + expect(withCodexFastModePayload({ model: "gpt" }, true)).toEqual({ + model: "gpt", + service_tier: CODEX_FAST_MODE_SERVICE_TIER, + }); + expect(withCodexFastModePayload({ service_tier: "default" }, true)).toEqual({ service_tier: "default" }); + }); +}); diff --git a/packages/coding-agent/test/fast-mode-selector.test.ts b/packages/coding-agent/test/fast-mode-selector.test.ts new file mode 100644 index 000000000..e8411e9cf --- /dev/null +++ b/packages/coding-agent/test/fast-mode-selector.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from "vitest"; +import { FastModeSelectorComponent } from "../src/modes/interactive/components/fast-mode-selector.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; + +function plainRender(selector: FastModeSelectorComponent): string { + return selector + .render(120) + .join("\n") + .replace(/\u001b\[[0-9;]*m/g, ""); +} + +describe("FastModeSelectorComponent", () => { + it("renders chat and workflow rows", () => { + initTheme("dark"); + const selector = new FastModeSelectorComponent( + { chat: false, workflow: true }, + { onChange: () => {}, onCancel: () => {} }, + ); + + const rendered = plainRender(selector); + + expect(rendered).toContain("Codex fast mode"); + expect(rendered).toContain("chat"); + expect(rendered).toContain("workflow"); + expect(rendered).toContain("[disabled]"); + expect(rendered).toContain("[enabled]"); + }); + + it("moves rows with tab and shift-tab", () => { + initTheme("dark"); + const selector = new FastModeSelectorComponent( + { chat: false, workflow: false }, + { onChange: () => {}, onCancel: () => {} }, + ); + + expect(selector.getFocusedRow()).toBe("chat"); + selector.handleInput("\t"); + expect(selector.getFocusedRow()).toBe("workflow"); + selector.handleInput("\x1b[Z"); + expect(selector.getFocusedRow()).toBe("chat"); + }); + + it("changes the focused row with left and right arrows", () => { + initTheme("dark"); + const onChange = vi.fn(); + const selector = new FastModeSelectorComponent( + { chat: false, workflow: false }, + { onChange, onCancel: () => {} }, + ); + + selector.handleInput("\x1b[C"); + expect(selector.getSettings()).toEqual({ chat: true, workflow: false }); + expect(onChange).toHaveBeenLastCalledWith({ chat: true, workflow: false }); + + selector.handleInput("\t"); + selector.handleInput("\x1b[C"); + expect(selector.getSettings()).toEqual({ chat: true, workflow: true }); + expect(onChange).toHaveBeenLastCalledWith({ chat: true, workflow: true }); + + selector.handleInput("\x1b[D"); + expect(selector.getSettings()).toEqual({ chat: true, workflow: false }); + expect(onChange).toHaveBeenLastCalledWith({ chat: true, workflow: false }); + }); + + it("cancels on escape", () => { + initTheme("dark"); + const onCancel = vi.fn(); + const selector = new FastModeSelectorComponent( + { chat: false, workflow: false }, + { onChange: () => {}, onCancel }, + ); + + selector.handleInput("\x1b"); + + expect(onCancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index ff1cfd53f..3974b41b5 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -1,5 +1,6 @@ import { homedir } from "node:os"; import * as path from "node:path"; +import { type Api, type Model } from "@earendil-works/pi-ai"; import { type AutocompleteProvider, CombinedAutocompleteProvider, Container } from "@earendil-works/pi-tui"; import { beforeAll, describe, expect, test, vi } from "vitest"; import type { AutocompleteProviderFactory } from "../src/core/extensions/types.ts"; @@ -239,6 +240,70 @@ describe("InteractiveMode.setupAutocompleteProvider", () => { }); }); +describe("InteractiveMode /fast autocomplete", () => { + function createModel(provider: string, id = `${provider}-model`): Model { + return { + id, + name: id, + api: "openai-completions", + provider, + baseUrl: `https://${provider}.example/v1`, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 4096, + }; + } + + function createProvider(models: Model[], scopedModels: Model[] = []): AutocompleteProvider { + const fakeThis: any = { + session: { + scopedModels: scopedModels.map((model) => ({ model })), + modelRegistry: { + getAvailable: vi.fn(() => models), + }, + promptTemplates: [], + extensionRunner: { getRegisteredCommands: () => [] }, + resourceLoader: { getSkills: () => ({ skills: [] }) }, + }, + settingsManager: { getEnableSkillCommands: () => true }, + sessionManager: { getCwd: () => process.cwd() }, + fdPath: undefined, + skillCommands: new Map(), + }; + Object.setPrototypeOf(fakeThis, (InteractiveMode as any).prototype); + return (InteractiveMode as any).prototype.createBaseAutocompleteProvider.call(fakeThis) as AutocompleteProvider; + } + + async function slashLabels(provider: AutocompleteProvider, prefix = "/fa"): Promise { + const suggestions = await provider.getSuggestions([prefix], 0, prefix.length, { + signal: new AbortController().signal, + }); + return suggestions?.items.map((item) => item.value) ?? []; + } + + test("shows /fast when an OpenAI model is available", async () => { + const labels = await slashLabels(createProvider([createModel("openai")])); + + expect(labels).toContain("fast"); + }); + + test("shows /fast when an OpenAI Codex scoped model is available", async () => { + const labels = await slashLabels( + createProvider([createModel("github-copilot")], [createModel("openai-codex")]), + ); + + expect(labels).toContain("fast"); + }); + + test("hides /fast when only GitHub Copilot models are available", async () => { + const labels = await slashLabels(createProvider([createModel("github-copilot")])); + + expect(labels).not.toContain("fast"); + }); +}); + describe("InteractiveMode.showLoadedResources", () => { beforeAll(() => { initTheme("dark"); diff --git a/packages/coding-agent/test/sdk-codex-fast-mode.test.ts b/packages/coding-agent/test/sdk-codex-fast-mode.test.ts new file mode 100644 index 000000000..5ec5eeac7 --- /dev/null +++ b/packages/coding-agent/test/sdk-codex-fast-mode.test.ts @@ -0,0 +1,195 @@ +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + type Api, + type AssistantMessage, + createAssistantMessageEventStream, + type Model, + type SimpleStreamOptions, +} from "@earendil-works/pi-ai"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { CODEX_FAST_MODE_SERVICE_TIER } from "../src/core/codex-fast-mode.ts"; +import type { OrchestrationContext } from "../src/core/extensions/index.ts"; +import { ModelRegistry } from "../src/core/model-registry.ts"; +import { createAgentSession } from "../src/core/sdk.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; + +interface CapturedFastModeRequest { + options: SimpleStreamOptions | undefined; + payload: unknown; +} + +function createModel(provider: string, api: Api): Model { + return { + id: `${provider}-test-model`, + name: `${provider} Test Model`, + api, + provider, + baseUrl: `https://${provider}.example/v1`, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 4096, + }; +} + +function createDoneStream(model: Model) { + const stream = createAssistantMessageEventStream(); + const message: AssistantMessage = { + role: "assistant", + content: [{ type: "text", text: "ok" }], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + stream.end(message); + return stream; +} + +const workflowContext: OrchestrationContext = { + kind: "workflow-stage", + workflowRunId: "run-1", + workflowStageId: "stage-1", + workflowStageName: "Stage 1", + constraints: { + disableWorkflowTool: true, + maxSubagentDepth: 0, + }, +}; + +describe("createAgentSession codex fast mode", () => { + let tempDir: string; + let cwd: string; + let agentDir: string; + let registeredProviders: Array<{ registry: ModelRegistry; provider: string }>; + + beforeEach(() => { + tempDir = join(tmpdir(), `atomic-sdk-codex-fast-${Date.now()}-${Math.random().toString(36).slice(2)}`); + cwd = join(tempDir, "project"); + agentDir = join(tempDir, "agent"); + mkdirSync(cwd, { recursive: true }); + mkdirSync(agentDir, { recursive: true }); + registeredProviders = []; + }); + + afterEach(() => { + for (const entry of registeredProviders.reverse()) { + entry.registry.unregisterProvider(entry.provider); + } + if (existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + async function captureFastModeRequest(options: { + provider: string; + settings: { chat: boolean; workflow: boolean }; + orchestrationContext?: OrchestrationContext; + payload?: Record; + }): Promise { + const api = `codex-fast-capture-${options.provider}-${Math.random().toString(36).slice(2)}` as Api; + const model = createModel(options.provider, api); + const authStorage = AuthStorage.create(join(agentDir, "auth.json")); + authStorage.setRuntimeApiKey(options.provider, "test-api-key"); + const modelRegistry = ModelRegistry.create(authStorage, join(agentDir, "models.json")); + const settingsManager = SettingsManager.inMemory({ codexFastMode: options.settings }); + const sessionManager = SessionManager.inMemory(cwd); + let capturedOptions: SimpleStreamOptions | undefined; + + modelRegistry.registerProvider(options.provider, { + api, + streamSimple: (_model, _context, streamOptions) => { + capturedOptions = streamOptions; + return createDoneStream(model); + }, + }); + registeredProviders.push({ registry: modelRegistry, provider: options.provider }); + + const { session } = await createAgentSession({ + cwd, + agentDir, + model, + authStorage, + modelRegistry, + settingsManager, + sessionManager, + orchestrationContext: options.orchestrationContext, + }); + + try { + await session.agent.streamFn(model, { messages: [] }, { sessionId: session.sessionId }); + const payload = await session.agent.onPayload?.(options.payload ?? { model: model.id }, model); + return { options: capturedOptions, payload }; + } finally { + session.dispose(); + modelRegistry.unregisterProvider(options.provider); + registeredProviders = registeredProviders.filter((entry) => entry.registry !== modelRegistry || entry.provider !== options.provider); + } + } + + it("adds priority service tier for enabled chat requests", async () => { + const captured = await captureFastModeRequest({ + provider: "openai", + settings: { chat: true, workflow: false }, + }); + + expect((captured.options as SimpleStreamOptions & { serviceTier?: string })?.serviceTier).toBe( + CODEX_FAST_MODE_SERVICE_TIER, + ); + expect(captured.payload).toMatchObject({ service_tier: CODEX_FAST_MODE_SERVICE_TIER }); + }); + + it("uses the workflow setting for workflow-stage requests", async () => { + const disabled = await captureFastModeRequest({ + provider: "openai-codex", + settings: { chat: true, workflow: false }, + orchestrationContext: workflowContext, + }); + expect((disabled.options as SimpleStreamOptions & { serviceTier?: string })?.serviceTier).toBeUndefined(); + expect(disabled.payload).not.toMatchObject({ service_tier: CODEX_FAST_MODE_SERVICE_TIER }); + + const enabled = await captureFastModeRequest({ + provider: "openai-codex", + settings: { chat: false, workflow: true }, + orchestrationContext: workflowContext, + }); + expect((enabled.options as SimpleStreamOptions & { serviceTier?: string })?.serviceTier).toBe( + CODEX_FAST_MODE_SERVICE_TIER, + ); + expect(enabled.payload).toMatchObject({ service_tier: CODEX_FAST_MODE_SERVICE_TIER }); + }); + + it("does not apply fast mode to GitHub Copilot", async () => { + const captured = await captureFastModeRequest({ + provider: "github-copilot", + settings: { chat: true, workflow: true }, + }); + + expect((captured.options as SimpleStreamOptions & { serviceTier?: string })?.serviceTier).toBeUndefined(); + expect(captured.payload).not.toMatchObject({ service_tier: CODEX_FAST_MODE_SERVICE_TIER }); + }); + + it("does not overwrite an existing provider payload service_tier", async () => { + const captured = await captureFastModeRequest({ + provider: "openai", + settings: { chat: true, workflow: false }, + payload: { service_tier: "default" }, + }); + + expect(captured.payload).toEqual({ service_tier: "default" }); + }); +}); diff --git a/packages/coding-agent/test/settings-manager-codex-fast-mode.test.ts b/packages/coding-agent/test/settings-manager-codex-fast-mode.test.ts new file mode 100644 index 000000000..e5706d598 --- /dev/null +++ b/packages/coding-agent/test/settings-manager-codex-fast-mode.test.ts @@ -0,0 +1,58 @@ +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { SettingsManager } from "../src/core/settings-manager.ts"; + +describe("SettingsManager codexFastMode", () => { + let tempDir: string; + let cwd: string; + let agentDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `atomic-codex-fast-settings-${Date.now()}-${Math.random().toString(36).slice(2)}`); + cwd = join(tempDir, "project"); + agentDir = join(tempDir, "agent"); + mkdirSync(cwd, { recursive: true }); + mkdirSync(agentDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("defaults chat and workflow fast mode to disabled", () => { + const manager = SettingsManager.inMemory(); + + expect(manager.getCodexFastModeSettings()).toEqual({ chat: false, workflow: false }); + }); + + it("persists chat and workflow fast mode settings", async () => { + const manager = SettingsManager.create(cwd, agentDir); + + manager.setCodexFastModeSettings({ chat: true, workflow: false }); + await manager.flush(); + + expect(manager.getCodexFastModeSettings()).toEqual({ chat: true, workflow: false }); + const saved = JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf-8")); + expect(saved.codexFastMode).toEqual({ chat: true, workflow: false }); + }); + + it("merges missing nested fields from global and project settings", () => { + writeFileSync( + join(agentDir, "settings.json"), + JSON.stringify({ codexFastMode: { chat: true } }, null, 2), + ); + mkdirSync(join(cwd, ".atomic"), { recursive: true }); + writeFileSync( + join(cwd, ".atomic", "settings.json"), + JSON.stringify({ codexFastMode: { workflow: true } }, null, 2), + ); + + const manager = SettingsManager.create(cwd, agentDir); + + expect(manager.getCodexFastModeSettings()).toEqual({ chat: true, workflow: true }); + }); +}); diff --git a/specs/2026-05-30-implement-github-issue-https-github-com-flora131-atomic-issues-1134-in-this-repo.md b/specs/2026-05-30-implement-github-issue-https-github-com-flora131-atomic-issues-1134-in-this-repo.md new file mode 100644 index 000000000..1c9b88ef4 --- /dev/null +++ b/specs/2026-05-30-implement-github-issue-https-github-com-flora131-atomic-issues-1134-in-this-repo.md @@ -0,0 +1,532 @@ +# Atomic Codex Fast Mode Technical Design Document / RFC + +| Document Metadata | Details | +| ---------------------- | -------------------------------------------- | +| Author(s) | Alex Lavaee | +| Status | Draft (WIP) | +| Team / Owner | Atomic Coding Agent Core / Workflow Runtime | +| Created / Last Updated | 2026-05-30 / 2026-05-30 | + +## 1. Executive Summary + +GitHub issue [flora131/atomic#1134](https://github.com/flora131/atomic/issues/1134) requests a user-facing Codex fast mode in Atomic. Users should be able to run `/fast` in the TUI, toggle fast mode separately for normal chat sessions and workflow stage sessions, and have Atomic invoke supported OpenAI inference providers with the correct priority-service setting. The command must only be visible when the current session has supported OpenAI-backed models available: `openai/*` or `openai-codex/*`, explicitly excluding GitHub Copilot/OpenAI models such as `github-copilot/*`. + +This RFC proposes implementing Codex fast mode as a first-party coding-agent core feature, not as an external extension. The implementation will add: + +- A persisted `codexFastMode` settings object in `packages/coding-agent/src/core/settings-manager.ts`. +- A small reusable fast-mode helper module for provider eligibility and request-option/payload mutation. +- A conditional built-in `/fast` command in `packages/coding-agent/src/core/slash-commands.ts` and `packages/coding-agent/src/modes/interactive/interactive-mode.ts`. +- A two-row TUI selector component with `chat: enabled/disabled` and `workflow: enabled/disabled`. +- Stream-option and provider-payload wiring in `packages/coding-agent/src/core/sdk.ts`, using `serviceTier: "priority"` where provider options support it and ensuring serialized payloads contain `service_tier: "priority"` for supported OpenAI providers. +- Documentation in `packages/coding-agent/docs/usage.md`, `packages/coding-agent/docs/settings.md`, and `packages/coding-agent/docs/providers.md`. +- Tests for visibility, settings persistence, provider eligibility, payload mutation, and workflow-stage selection. + +No prior review findings exist for this first iteration. + +## 2. Context and Motivation + +### 2.1 Current State + +Atomic already supports OpenAI and OpenAI Codex providers through the model registry and `@earendil-works/pi-ai` provider layer: + +- `packages/coding-agent/src/core/model-resolver.ts` maps `openai-codex` to a default model (`gpt-5.5`). +- `packages/coding-agent/src/core/model-registry.ts` loads built-in providers, custom `models.json` providers, and configured auth. `getAvailable()` returns models whose provider has configured credentials. +- `packages/coding-agent/docs/providers.md` documents OpenAI API-key usage and OpenAI Codex subscription usage through `/login`. +- `packages/coding-agent/docs/custom-provider.md` documents `openai-responses` and `openai-codex-responses` API types. + +Slash command visibility is currently centralized in two places: + +- `packages/coding-agent/src/core/slash-commands.ts` defines `BUILTIN_SLASH_COMMANDS`, including `/settings`, `/model`, `/scoped-models`, `/login`, `/logout`, and other built-ins. +- `packages/coding-agent/src/modes/interactive/interactive-mode.ts` converts `BUILTIN_SLASH_COMMANDS` into autocomplete entries in `createBaseAutocompleteProvider()` and handles built-in command submission in `setupEditorSubmitHandler()`. + +Settings are persisted through `SettingsManager`: + +- `packages/coding-agent/src/core/settings-manager.ts` defines the `Settings` interface and typed getters/setters for fields such as `transport`, `steeringMode`, `followUpMode`, `warnings`, and `enabledModels`. +- Global settings live at `~/.atomic/agent/settings.json`; project settings live at `.atomic/settings.json`, as documented in `packages/coding-agent/docs/settings.md`. +- Settings writes are queued and can be awaited with `settingsManager.flush()`. + +Provider requests flow through the SDK stream function: + +- `packages/coding-agent/src/core/sdk.ts` constructs an `Agent` with `streamFn`, resolves auth via `modelRegistry.getApiKeyAndHeaders()`, and calls `streamSimple(model, context, options)`. +- The same file wires `onPayload` to `ExtensionRunner.emitBeforeProviderRequest()`, enabling provider-payload rewrites before the request is sent. +- `packages/coding-agent/docs/extensions.md` documents `before_provider_request` as a hook that can inspect or replace the provider payload. + +Workflow stages create child `AgentSession`s through the workflows extension: + +- `packages/workflows/src/extension/wiring.ts` calls Atomic’s `createAgentSession()` for workflow stages. +- `withWorkflowStageSessionOptions()` attaches `orchestrationContext.kind === "workflow-stage"` to child sessions. +- `packages/coding-agent/src/core/extensions/runner.ts` exposes `ctx.orchestrationContext` to extension handlers. + +The issue includes a reference implementation: [calesennett/pi-codex-fast](https://github.com/calesennett/pi-codex-fast). Its `extensions/codex-fast.ts` toggles an extension setting and injects `service_tier: "priority"` in `before_provider_request` when `ctx.model?.provider` is `openai` or `openai-codex`. That validates the basic request-level mechanism, but Atomic needs a built-in `/fast` UI with separate chat/workflow controls and conditional command visibility. + +### 2.2 The Problem + +Users currently have to wire Codex fast mode themselves, typically by installing an extension or manually patching provider request payloads. This creates several problems: + +1. **No built-in discovery path**: `/fast` does not exist in `BUILTIN_SLASH_COMMANDS`, so users do not discover fast mode through command completion. +2. **No conditional visibility**: a naïve built-in command would appear for users whose configured models cannot use it, contrary to issue #1134. +3. **No chat/workflow split**: workflows run separate child sessions. Users need to enable fast mode for chat, workflow stages, or both. +4. **Provider specificity is easy to get wrong**: fast mode must apply only to `openai` and `openai-codex` providers, not GitHub Copilot models that may expose OpenAI-family model IDs under `github-copilot`. +5. **Payload-only injection may miss accounting details**: upstream `openai-responses` and `openai-codex-responses` provider code accepts `serviceTier` options and serializes them as `service_tier`; using only `onPayload` can set the request field but may not preserve service-tier-aware usage accounting in provider processing. + +## 3. Goals and Non-Goals + +### 3.1 Functional Goals + +1. Add a user-facing `/fast` slash command in interactive mode. +2. Show `/fast` in autocomplete only when the current session’s selectable or available model set includes at least one supported provider: + - supported: `openai/*`, `openai-codex/*` + - unsupported: `github-copilot/*`, Azure, OpenRouter, OpenCode, and all other providers +3. When `/fast` runs, replace the editor with a focused two-row configuration UI: + - `chat: enabled/disabled` + - `workflow: enabled/disabled` +4. Let users move between `chat` and `workflow` rows with Tab and Shift+Tab. +5. Let users change enabled/disabled values with left/right arrow keys. +6. Persist fast-mode state in Atomic settings, defaulting both rows to disabled. +7. Apply chat fast mode to normal, non-workflow `AgentSession` provider requests. +8. Apply workflow fast mode to child workflow-stage sessions identified by `orchestrationContext.kind === "workflow-stage"`. +9. Invoke supported OpenAI providers with priority service tier: + - add `serviceTier: "priority"` to stream options when supported by the provider layer + - ensure the provider payload includes `service_tier: "priority"` unless already set +10. Leave all existing non-fast behavior unchanged when fast mode is disabled. +11. Add tests or regression coverage for settings, command visibility, UI state changes, provider eligibility, payload mutation, and workflow-stage selection. +12. Update user docs under `packages/coding-agent/docs`. + +### 3.2 Non-Goals (Out of Scope) + +1. Do not publish, release, or tag any package as part of this issue. +2. Do not add a CLI flag such as `atomic --fast` in this iteration. The issue requests the `/fast` slash command. +3. Do not support GitHub Copilot fast mode, even when the model ID looks OpenAI-like. +4. Do not change the default model, default thinking level, or transport behavior. +5. Do not force workflows to use OpenAI models. Fast mode only affects workflow stages that already choose `openai` or `openai-codex`. +6. Do not add fast mode to third-party OpenAI-compatible providers such as OpenRouter, Vercel AI Gateway, Cloudflare AI Gateway, or custom `models.json` providers in this iteration. +7. Do not redesign `/settings`; `/fast` should be a focused command-specific selector. +8. Do not remove or replace the existing extension `before_provider_request` hook. +9. Do not migrate existing settings files. Missing `codexFastMode` means disabled. +10. Do not implement code changes in this RFC stage. + +## 4. Proposed Solution (High-Level Design) + +Implement first-party Codex fast mode in `packages/coding-agent`. + +At a high level: + +1. Add `codexFastMode?: { chat?: boolean; workflow?: boolean }` to `Settings`. +2. Add `getCodexFastModeSettings()` and `setCodexFastModeSettings()` to `SettingsManager`. +3. Add a helper module, for example `packages/coding-agent/src/core/codex-fast-mode.ts`, containing: + - `isCodexFastModeSupportedModel(model)` + - `hasSupportedCodexFastModeModel(models)` + - `isWorkflowStageSession(orchestrationContext)` + - `isCodexFastModeEnabledForSession(settings, orchestrationContext)` + - `withCodexFastModeStreamOptions(...)` + - `withCodexFastModePayload(...)` +4. Extend `interactive-mode.ts` so `/fast` is included in autocomplete only when supported models are available. +5. Add `FastModeSelectorComponent` under `packages/coding-agent/src/modes/interactive/components/`. +6. Wire `/fast` submission to `showFastModeSelector()`. +7. Apply fast mode in `sdk.ts` before calling `streamSimple()` and before extension payload hooks run. +8. Update docs and tests. + +### 4.1 System Architecture Diagram + +```mermaid +flowchart TD + User["User in Atomic TUI"] + Editor["Interactive editor
interactive-mode.ts"] + Autocomplete["Command autocomplete
createBaseAutocompleteProvider()"] + Slash["/fast command handler
setupEditorSubmitHandler()"] + Selector["FastModeSelectorComponent
chat/workflow rows"] + Settings["SettingsManager
codexFastMode.chat
codexFastMode.workflow"] + SDK["createAgentSession() streamFn
packages/coding-agent/src/core/sdk.ts"] + Helper["codex-fast-mode helper
eligibility, scope, payload/options"] + ChatSession["Normal chat AgentSession
no workflow orchestrationContext"] + WorkflowExt["Workflow extension
packages/workflows/src/extension/wiring.ts"] + StageSession["Workflow stage AgentSession
orchestrationContext.kind = workflow-stage"] + Registry["ModelRegistry.getAvailable()
provider auth and availability"] + OpenAI["Supported providers
openai/*
openai-codex/*"] + GitHub["Unsupported provider
github-copilot/*"] + Payload["Provider request
serviceTier option
service_tier payload"] + Provider["OpenAI / OpenAI Codex inference"] + + User --> Editor + Editor --> Autocomplete + Autocomplete --> Registry + Registry --> OpenAI + Registry --> GitHub + OpenAI -->|enables visibility| Autocomplete + GitHub -->|ignored for fast mode| Autocomplete + Editor --> Slash + Slash --> Selector + Selector --> Settings + Settings --> SDK + SDK --> Helper + ChatSession --> SDK + WorkflowExt --> StageSession + StageSession --> SDK + Helper -->|chat setting for normal sessions| ChatSession + Helper -->|workflow setting for workflow-stage sessions| StageSession + Helper --> Payload + Payload --> Provider +``` + +### 4.2 Architectural Pattern + +The proposed pattern is **core feature with centralized policy helper**. + +- Slash command registration stays with built-in command infrastructure in `slash-commands.ts` and `interactive-mode.ts`. +- UI state is local to a dedicated TUI component, matching existing selector components such as `SettingsSelectorComponent` in `packages/coding-agent/src/modes/interactive/components/settings-selector.ts`. +- Persistence stays in `SettingsManager`, matching existing settings such as `transport`, `warnings`, and `enabledModels`. +- Provider-specific behavior is centralized in a helper rather than scattered across `interactive-mode.ts`, `sdk.ts`, and workflow code. +- Workflow detection uses existing `orchestrationContext.kind === "workflow-stage"`, already set by `packages/workflows/src/extension/wiring.ts`. + +This avoids making fast mode an extension while still reusing the same provider-payload seam that extensions use. + +### 4.3 Key Components + +| Component | Responsibility | Technology Stack | Justification | +| --------- | -------------- | ---------------- | ------------- | +| `packages/coding-agent/src/core/codex-fast-mode.ts` | Central predicate and mutation helpers for supported providers, chat/workflow scope, stream options, and payloads. | TypeScript ESM | Keeps provider policy testable and prevents duplicated `openai` / `openai-codex` checks. | +| `SettingsManager` in `packages/coding-agent/src/core/settings-manager.ts` | Persist `codexFastMode.chat` and `codexFastMode.workflow` with defaults false. | TypeScript, JSON settings | Existing settings layer already handles global/project merge and async writes. | +| `BUILTIN_SLASH_COMMANDS` in `packages/coding-agent/src/core/slash-commands.ts` | Add metadata for `/fast`. | TypeScript | Built-in command list drives interactive autocomplete and conflict diagnostics. | +| `InteractiveMode` in `packages/coding-agent/src/modes/interactive/interactive-mode.ts` | Conditionally expose `/fast`, handle `/fast`, and mount the fast-mode selector. | TypeScript, `@earendil-works/pi-tui` | Current built-in slash-command submit and autocomplete logic live here. | +| `FastModeSelectorComponent` | Two-row command UI with Tab row navigation and arrow-key value changes. | `@earendil-works/pi-tui` components and theme helpers | Satisfies the issue’s explicit TUI behavior while matching Atomic’s selector style. | +| `createAgentSession()` stream function in `packages/coding-agent/src/core/sdk.ts` | Add `serviceTier: "priority"` and ensure `service_tier` payload for eligible requests. | TypeScript, `@earendil-works/pi-ai` `streamSimple` | All chat and workflow stage LLM requests pass through this SDK stream path. | +| `packages/workflows/src/extension/wiring.ts` | Existing workflow-stage `orchestrationContext` source. | TypeScript | No workflow code change should be needed beyond tests, because stage sessions already identify themselves. | +| Docs in `packages/coding-agent/docs` | Explain `/fast`, supported providers, settings keys, and when to use it. | Markdown | Required by issue acceptance criteria. | +| Tests under `packages/coding-agent/test` | Validate settings, predicates, command visibility, UI, and provider request mutation. | Bun commands running existing test stack | Prevents regressions in the configuration path. | + +## 5. Detailed Design + +### 5.1 API Interfaces + +Add settings types in `packages/coding-agent/src/core/settings-manager.ts`: + +```ts +export interface CodexFastModeSettings { + chat?: boolean; + workflow?: boolean; +} + +export interface Settings { + codexFastMode?: CodexFastModeSettings; +} +``` + +Add typed accessors: + +```ts +getCodexFastModeSettings(): { chat: boolean; workflow: boolean }; + +setCodexFastModeSettings(settings: { + chat: boolean; + workflow: boolean; +}): void; +``` + +Add a helper API in `packages/coding-agent/src/core/codex-fast-mode.ts`: + +```ts +export function isCodexFastModeSupportedProvider(provider: string): boolean; +// true only for "openai" and "openai-codex" + +export function isCodexFastModeSupportedModel(model: Pick, "provider">): boolean; + +export function hasSupportedCodexFastModeModel(models: readonly Pick, "provider">[]): boolean; + +export function isWorkflowStageOrchestrationContext( + context: OrchestrationContext | undefined, +): boolean; + +export function getCodexFastModeScope( + context: OrchestrationContext | undefined, +): "chat" | "workflow"; + +export function isCodexFastModeEnabled( + settings: { chat: boolean; workflow: boolean }, + context: OrchestrationContext | undefined, +): boolean; + +export function withCodexFastModePayload(payload: unknown): unknown; +// If payload is an object and lacks service_tier, return { ...payload, service_tier: "priority" }. +// Otherwise return payload unchanged. +``` + +In `sdk.ts`, compute fast mode once per stream call: + +```ts +const fastModeEnabled = + isCodexFastModeSupportedModel(model) && + isCodexFastModeEnabled( + settingsManager.getCodexFastModeSettings(), + options.orchestrationContext, + ); +``` + +When enabled: + +- Add `serviceTier: "priority"` to the provider options object passed to `streamSimple()` for providers that read it. +- Wrap `onPayload` so Atomic first ensures `service_tier: "priority"` on supported provider payloads, then existing extension `before_provider_request` handlers can inspect or override the resulting payload. + +The ordering should be: + +1. Provider builds params from stream options. +2. Atomic core fast-mode payload guard runs. +3. Extension `before_provider_request` handlers run in existing load order. +4. Provider sends final payload. + +This keeps the built-in behavior deterministic while preserving extension override power. + +### 5.2 Data Model / Schema + +Persist fast mode in settings: + +```json +{ + "codexFastMode": { + "chat": true, + "workflow": false + } +} +``` + +Semantics: + +| Field | Type | Default | Meaning | +| ----- | ---- | ------- | ------- | +| `codexFastMode.chat` | boolean | `false` | Apply priority service tier to supported OpenAI provider requests from normal chat sessions. | +| `codexFastMode.workflow` | boolean | `false` | Apply priority service tier to supported OpenAI provider requests from workflow stage sessions. | + +Merge behavior follows existing settings behavior: + +- Global settings provide defaults. +- Project settings override global settings. +- Missing nested fields default to `false`. +- Setter writes to global settings, matching current `/settings` behavior for most user preferences. + +No session-file schema migration is required. If a session is resumed, fast mode is read from current settings, not from historical transcript entries. + +### 5.3 Algorithms and State Management + +#### Command visibility + +`InteractiveMode.createBaseAutocompleteProvider()` should build candidate models using the same source as `/model`: + +1. If `session.scopedModels.length > 0`, inspect scoped models. +2. Otherwise call `session.modelRegistry.getAvailable()`. +3. Include `/fast` only if any candidate has `provider === "openai"` or `provider === "openai-codex"`. + +This makes `/fast` visible only when the current session has supported OpenAI models available. A user with only `github-copilot/*` should not see `/fast`. + +#### Command execution + +When the editor submits `/fast`: + +1. Re-check supported model availability. +2. If no supported model is available, clear the editor and show an informational warning. +3. If supported, mount `FastModeSelectorComponent`. +4. The component initializes from `settingsManager.getCodexFastModeSettings()`. +5. Left/right changes update component-local state and call `setCodexFastModeSettings()`. +6. On cancel/escape, restore the editor. +7. Prefer awaiting `settingsManager.flush()` before final close if the component has pending changes, so a workflow launched immediately afterward sees the updated workflow setting. + +#### TUI behavior + +`FastModeSelectorComponent` should be intentionally small: + +- Header: `Codex fast mode` +- Description: `Uses OpenAI priority service tier for supported openai/* and openai-codex/* models.` +- Row 1: `chat [enabled] [disabled]` +- Row 2: `workflow [enabled] [disabled]` +- Tab and Shift+Tab move focus between rows. +- Left/right select `enabled` or `disabled`. +- Escape returns to chat. +- Muted footer hint: `tab row · ←/→ change · esc close` + +The selected row should use the existing theme accent from `packages/coding-agent/src/modes/interactive/theme/theme.ts`. Avoid decorative UI; match existing settings selector density. + +#### Provider request mutation + +For each stream call in `sdk.ts`: + +1. Determine if the model provider is supported. +2. Determine scope from `orchestrationContext`: + - `"workflow"` when `kind === "workflow-stage"` + - `"chat"` otherwise +3. Check the corresponding settings boolean. +4. If false, preserve existing stream options and payload behavior. +5. If true: + - pass a provider options object that includes `serviceTier: "priority"` where possible + - ensure object payloads contain `service_tier: "priority"` if absent + - do not overwrite an existing `service_tier` field set earlier by provider options or another source + +This should work for: + +- `openai-codex` models using `openai-codex-responses` +- `openai` models using `openai-responses` +- `openai` models using `openai-completions`, via payload mutation + +It intentionally excludes: + +- `github-copilot`, even when the model ID is `gpt-*` +- `azure-openai-responses` +- custom OpenAI-compatible providers whose provider ID is not exactly `openai` or `openai-codex` + +## 6. Alternatives Considered + +| Option | Pros | Cons | Reason for Rejection | +| ------ | ---- | ---- | -------------------- | +| Ship or vendor the external `pi-codex-fast` extension | Proven reference; small implementation; already uses `before_provider_request`. | Command would be extension-owned, named `/codex-fast`, not `/fast`; no built-in conditional visibility; no chat/workflow split; relies on private `SettingsManager` internals in the reference implementation. | Does not meet issue #1134 UI and command requirements. | +| Implement only a payload rewrite in `before_provider_request` style | Minimal code; works for serialized request body; easy to test. | May bypass provider-level `serviceTier` option handling and usage/cost accounting in `openai-responses` and `openai-codex-responses`; scatters first-party behavior through extension-like hooks. | Use payload mutation as a safety layer, but primary core wiring should set stream options where supported. | +| Add one global `fastMode: boolean` setting | Simpler UI and data model. | Does not satisfy the required two rows for `chat` and `workflow`; users may want fast workflows but normal chat, or vice versa. | Rejected because the issue explicitly requires separate chat/workflow configuration. | +| Always show `/fast` and show an error for unsupported providers | Simple command list; less async autocomplete work. | Violates “ONLY exists when the user has a supported fast mode model”; creates confusing UI for GitHub Copilot-only users. | Rejected because conditional visibility is an acceptance requirement. | +| Add `--fast` CLI flag instead of slash command | Useful for automation; mirrors the reference extension. | Issue asks for a user-facing slash command and TUI selector; CLI semantics for chat/workflow split are ambiguous. | Defer CLI support to a future issue if requested. | + +## 7. Cross-Cutting Concerns + +### 7.1 Security and Privacy + +Fast mode changes provider service tier, not credential handling or data routing. It must not: + +- expose API keys in logs or docs +- alter auth lookup in `ModelRegistry` +- send requests to a different provider +- apply to GitHub Copilot or other subscription providers without explicit support + +Because priority service tier may affect billing or subscription consumption, docs and UI copy should clearly state that fast mode uses OpenAI priority service tier for supported OpenAI providers. + +### 7.2 Observability Strategy + +The feature should be observable through existing mechanisms: + +- Add focused unit tests proving payloads receive `service_tier: "priority"` only when enabled and supported. +- Optionally show a TUI status or notification when `/fast` changes state. This is useful but not required by the issue. +- Avoid logging provider payloads by default, since payloads can include user prompts. + +If a status indicator is added, use a compact label such as `fast` only when the current active model is supported and the current scope setting is enabled. + +### 7.3 Scalability and Capacity Planning + +The runtime overhead is negligible: + +- Command visibility checks inspect the existing available model list. +- Per-request fast-mode checks are simple boolean and provider comparisons. +- Payload mutation is a shallow object copy only when enabled, supported, and the payload is object-like. + +The primary capacity risk is user cost or quota consumption from `service_tier: "priority"`, especially for workflow stages that can fan out. The split `workflow` toggle mitigates this by letting users keep workflows on default tier while enabling fast chat. + +## 8. Migration, Rollout, and Testing + +### 8.1 Deployment Strategy + +This is a normal package change in `packages/coding-agent`. + +Implementation rollout should: + +1. Create or reuse the separate worktree at `../atomic-issue-1134`. +2. Implement the settings, helper, TUI, SDK, docs, and tests. +3. Add a changelog entry under `packages/coding-agent/CHANGELOG.md` `## [Unreleased]`, likely `### Added`. +4. Run focused tests and typecheck with Bun commands only. +5. Commit changes on an issue branch. +6. Push and create or update a PR if appropriate. +7. Do not publish, release, or tag. + +### 8.2 Data Migration Plan + +No migration is required. + +Existing users have no `codexFastMode` setting, so both toggles default to disabled: + +```json +{ + "codexFastMode": { + "chat": false, + "workflow": false + } +} +``` + +If a user manually adds only one nested value, missing values default to false: + +```json +{ + "codexFastMode": { + "workflow": true + } +} +``` + +This means workflow fast mode is enabled and chat fast mode remains disabled. + +### 8.3 Test Plan + +Add or update tests in these areas: + +1. **Helper tests** + - `isCodexFastModeSupportedProvider("openai") === true` + - `isCodexFastModeSupportedProvider("openai-codex") === true` + - `isCodexFastModeSupportedProvider("github-copilot") === false` + - payload helper adds `service_tier: "priority"` only to object payloads without an existing `service_tier` + +2. **Settings tests** + - `SettingsManager.inMemory()` defaults both fast-mode values to false + - setter persists both booleans + - project settings override global settings for nested values if applicable + +3. **Interactive command visibility tests** + - `/fast` appears in autocomplete when available models include `openai/*` + - `/fast` appears when available models include `openai-codex/*` + - `/fast` does not appear with only `github-copilot/*` + - `/fast` does not appear when no supported models have configured auth + +4. **TUI component tests** + - initial render shows exactly `chat` and `workflow` rows + - Tab moves between rows + - left/right changes enabled/disabled + - callbacks receive updated settings + +5. **SDK/provider request tests** + - chat scope enabled adds `service_tier: "priority"` for `openai` + - workflow scope disabled does not add `service_tier` + - workflow scope enabled with `orchestrationContext.kind === "workflow-stage"` adds `service_tier` + - `github-copilot` never receives `service_tier` + - existing `service_tier` is not overwritten + +6. **Workflow integration tests** + - A workflow stage session created through `packages/workflows/src/extension/wiring.ts` carries `orchestrationContext.kind === "workflow-stage"` and uses the workflow setting rather than the chat setting. + +Recommended validation commands, using Bun only: + +```sh +bun run typecheck +bun --cwd packages/coding-agent run test -- test/settings-manager.test.ts +bun --cwd packages/coding-agent run test -- test/interactive-mode-status.test.ts +bun --cwd packages/coding-agent run test -- test/extensions-runner.test.ts +bun --cwd packages/coding-agent run docs:check +``` + +If new tests are placed under root `test/unit`, also run: + +```sh +bun run test:unit +``` + +## 9. Open Questions / Unresolved Issues + +1. **Should `/fast` visibility respect `--models` scoped model lists or all configured available models?** + Proposed answer: respect the current session’s model candidates, matching `/model` behavior. + Owner: [OWNER: coding-agent maintainers] + +2. **Should fast mode also support `azure-openai-responses` if Azure exposes a compatible priority tier?** + Proposed answer: no for this issue, because #1134 explicitly says OpenAI inference only and excludes non-OpenAI providers. + Owner: [OWNER: provider integrations] + +3. **Should a future `--fast` CLI flag be added for non-interactive mode?** + Proposed answer: defer. The current issue requests `/fast`; adding CLI semantics for separate chat/workflow toggles needs a separate design. + Owner: [OWNER: CLI maintainers] + +4. **Should Atomic display a persistent footer status when fast mode is enabled?** + Proposed answer: optional. The issue requires a selector, not a status indicator. If implemented, it should be compact and only active for supported current providers. + Owner: [OWNER: TUI maintainers] + +5. **Should settings writes from `/fast` always await `settingsManager.flush()` before returning to chat?** + Proposed answer: yes if practical, to avoid a race where a workflow starts immediately after toggling workflow fast mode. + Owner: [OWNER: coding-agent maintainers] From c81991c8e11d86d49cec5aa7edec4edcc33e794b Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Sat, 30 May 2026 12:54:11 -0700 Subject: [PATCH 02/16] refactor(coding-agent): simplify Codex fast mode wiring Assistant-model: GPT-5.5 --- packages/coding-agent/src/core/sdk.ts | 18 ++++++++---------- .../components/fast-mode-selector.ts | 5 ++--- .../src/modes/interactive/components/index.ts | 6 +++++- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 1ee7450ac..e29e44f4e 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -393,6 +393,12 @@ export async function createAgentSession( }; const extensionRunnerRef: { current?: ExtensionRunner } = {}; + const isCodexFastModeEnabled = (requestModel: Model): boolean => + shouldApplyCodexFastMode( + requestModel, + settingsManager.getCodexFastModeSettings(), + options.orchestrationContext, + ); agent = new Agent({ initialState: { @@ -409,11 +415,7 @@ export async function createAgentSession( } const providerRetrySettings = settingsManager.getProviderRetrySettings(); const attributionHeaders = getAttributionHeaders(model, settingsManager, streamOptions?.sessionId); - const fastModeEnabled = shouldApplyCodexFastMode( - model, - settingsManager.getCodexFastModeSettings(), - options.orchestrationContext, - ); + const fastModeEnabled = isCodexFastModeEnabled(model); return streamSimple( model, context, @@ -435,11 +437,7 @@ export async function createAgentSession( ); }, onPayload: async (payload, model) => { - const fastModeEnabled = shouldApplyCodexFastMode( - model, - settingsManager.getCodexFastModeSettings(), - options.orchestrationContext, - ); + const fastModeEnabled = isCodexFastModeEnabled(model); const guardedPayload = withCodexFastModePayload(payload, fastModeEnabled); const runner = extensionRunnerRef.current; if (!runner?.hasHandlers("before_provider_request")) { diff --git a/packages/coding-agent/src/modes/interactive/components/fast-mode-selector.ts b/packages/coding-agent/src/modes/interactive/components/fast-mode-selector.ts index cfc0c15b6..9c37e4c90 100644 --- a/packages/coding-agent/src/modes/interactive/components/fast-mode-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/fast-mode-selector.ts @@ -14,6 +14,7 @@ export interface FastModeSelectorCallbacks { type FastModeRow = keyof FastModeSelectorConfig; const ROWS: readonly FastModeRow[] = ["chat", "workflow"]; +const DESCRIPTION = "Uses OpenAI priority service tier for supported openai/* and openai-codex/* models."; export class FastModeSelectorComponent { private selectedRowIndex = 0; @@ -29,9 +30,7 @@ export class FastModeSelectorComponent { render(width: number): string[] { const lines: string[] = [theme.bold(theme.fg("accent", "Codex fast mode")), ""]; - const description = - "Uses OpenAI priority service tier for supported openai/* and openai-codex/* models."; - for (const line of wrapTextWithAnsi(description, Math.max(20, width))) { + for (const line of wrapTextWithAnsi(DESCRIPTION, Math.max(20, width))) { lines.push(theme.fg("muted", line)); } lines.push(""); diff --git a/packages/coding-agent/src/modes/interactive/components/index.ts b/packages/coding-agent/src/modes/interactive/components/index.ts index a8bb8e5f9..6dad44ebf 100644 --- a/packages/coding-agent/src/modes/interactive/components/index.ts +++ b/packages/coding-agent/src/modes/interactive/components/index.ts @@ -37,7 +37,11 @@ export { DynamicBorder } from "./dynamic-border.ts"; export { ExtensionEditorComponent } from "./extension-editor.ts"; export { ExtensionInputComponent } from "./extension-input.ts"; export { ExtensionSelectorComponent } from "./extension-selector.ts"; -export { FastModeSelectorComponent, type FastModeSelectorCallbacks, type FastModeSelectorConfig } from "./fast-mode-selector.ts"; +export { + FastModeSelectorComponent, + type FastModeSelectorCallbacks, + type FastModeSelectorConfig, +} from "./fast-mode-selector.ts"; export { FooterComponent, UsageMeterComponent } from "./footer.ts"; export { keyHint, keyText, rawKeyHint } from "./keybinding-hints.ts"; export { LoginDialogComponent } from "./login-dialog.ts"; From 1468542419b2cc2510cf0f197e2082a909b44b83 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Sat, 30 May 2026 13:16:54 -0700 Subject: [PATCH 03/16] fix(coding-agent): tighten fast command autocomplete Filter scoped fast-mode candidates by configured auth so stale scoped OpenAI models do not expose /fast after logout. Reserve the full built-in slash command namespace when filtering extension commands so hidden built-ins cannot be shadowed in autocomplete. AI-Assisted-By: OpenAI Codex --- .../src/modes/interactive/interactive-mode.ts | 14 +++-- .../test/interactive-mode-status.test.ts | 60 ++++++++++++++++++- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index e470f1fe5..3558ec4dd 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -563,9 +563,13 @@ export class InteractiveMode { } private getCodexFastModeCandidateModels(): Model[] { - return this.session.scopedModels.length > 0 - ? this.session.scopedModels.map((scoped) => scoped.model) - : this.session.modelRegistry.getAvailable(); + if (this.session.scopedModels.length > 0) { + return this.session.scopedModels + .map((scoped) => scoped.model) + .filter((model) => this.session.modelRegistry.hasConfiguredAuth(model)); + } + + return this.session.modelRegistry.getAvailable(); } private hasCodexFastModeSupportedModels(): boolean { @@ -636,7 +640,9 @@ export class InteractiveMode { ); // Convert extension commands to SlashCommand format - const builtinCommandNames = new Set(slashCommands.map((c) => c.name)); + const builtinCommandNames = new Set( + BUILTIN_SLASH_COMMANDS.map((command) => command.name), + ); const extensionCommands: SlashCommand[] = this.session.extensionRunner .getRegisteredCommands() .filter((cmd) => !builtinCommandNames.has(cmd.name)) diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 3974b41b5..6fff4a226 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -256,15 +256,44 @@ describe("InteractiveMode /fast autocomplete", () => { }; } - function createProvider(models: Model[], scopedModels: Model[] = []): AutocompleteProvider { + type ExtensionCommandFixture = { + name: string; + invocationName?: string; + description?: string; + }; + + function createProvider( + models: Model[], + scopedModels: Model[] = [], + options: { + hasConfiguredAuth?: (model: Model) => boolean; + extensionCommands?: ExtensionCommandFixture[]; + } = {}, + ): AutocompleteProvider { const fakeThis: any = { session: { scopedModels: scopedModels.map((model) => ({ model })), modelRegistry: { getAvailable: vi.fn(() => models), + hasConfiguredAuth: vi.fn(options.hasConfiguredAuth ?? (() => true)), }, promptTemplates: [], - extensionRunner: { getRegisteredCommands: () => [] }, + extensionRunner: { + getRegisteredCommands: () => + (options.extensionCommands ?? []).map((command) => ({ + name: command.name, + invocationName: command.invocationName ?? command.name, + description: command.description, + sourceInfo: { + path: `/tmp/extensions/${command.name}.ts`, + source: "test", + scope: "project" as const, + origin: "top-level" as const, + baseDir: "/tmp/extensions", + }, + handler: vi.fn(), + })), + }, resourceLoader: { getSkills: () => ({ skills: [] }) }, }, settingsManager: { getEnableSkillCommands: () => true }, @@ -302,6 +331,33 @@ describe("InteractiveMode /fast autocomplete", () => { expect(labels).not.toContain("fast"); }); + + test("hides /fast for unauthenticated scoped OpenAI models without falling back", async () => { + for (const scopedProvider of ["openai", "openai-codex"]) { + const scopedModel = createModel(scopedProvider, `${scopedProvider}-unauthenticated`); + const labels = await slashLabels( + createProvider([createModel("openai", "available-openai")], [scopedModel], { + hasConfiguredAuth: (model) => model !== scopedModel, + }), + ); + + expect(labels).not.toContain("fast"); + } + }); + + test("hides extension /fast when the built-in command is hidden", async () => { + const labels = await slashLabels( + createProvider([createModel("github-copilot")], [], { + extensionCommands: [ + { name: "fast", description: "Extension fast command" }, + { name: "faster", description: "Non-conflicting extension command" }, + ], + }), + ); + + expect(labels).not.toContain("fast"); + expect(labels).toContain("faster"); + }); }); describe("InteractiveMode.showLoadedResources", () => { From 27538260b75cb9d2a910f19ab06a9358278de54a Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Sat, 30 May 2026 13:20:37 -0700 Subject: [PATCH 04/16] refactor(coding-agent): reuse builtin slash command names --- .../src/modes/interactive/interactive-mode.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 3558ec4dd..862964b8f 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -257,6 +257,10 @@ function isDeadTerminalError(error: unknown): boolean { const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING = "Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage."; +const BUILTIN_SLASH_COMMAND_NAMES = new Set( + BUILTIN_SLASH_COMMANDS.map((command) => command.name), +); + function isAnthropicSubscriptionAuthKey(apiKey: string | undefined): boolean { return typeof apiKey === "string" && apiKey.startsWith("sk-ant-oat"); } @@ -546,12 +550,9 @@ export class InteractiveMode { private getBuiltInCommandConflictDiagnostics( extensionRunner: ExtensionRunner, ): ResourceDiagnostic[] { - const builtinNames = new Set( - BUILTIN_SLASH_COMMANDS.map((command) => command.name), - ); return extensionRunner .getRegisteredCommands() - .filter((command) => builtinNames.has(command.name)) + .filter((command) => BUILTIN_SLASH_COMMAND_NAMES.has(command.name)) .map((command) => ({ type: "warning" as const, message: @@ -640,12 +641,9 @@ export class InteractiveMode { ); // Convert extension commands to SlashCommand format - const builtinCommandNames = new Set( - BUILTIN_SLASH_COMMANDS.map((command) => command.name), - ); const extensionCommands: SlashCommand[] = this.session.extensionRunner .getRegisteredCommands() - .filter((cmd) => !builtinCommandNames.has(cmd.name)) + .filter((cmd) => !BUILTIN_SLASH_COMMAND_NAMES.has(cmd.name)) .map((cmd) => ({ name: cmd.invocationName, description: this.prefixAutocompleteDescription( From 9099b10d80b4f899cbcb569a28314518e62b37f5 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Sat, 30 May 2026 18:50:09 -0700 Subject: [PATCH 05/16] fix(coding-agent): show active codex fast mode --- packages/coding-agent/CHANGELOG.md | 2 +- packages/coding-agent/docs/providers.md | 2 +- packages/coding-agent/docs/settings.md | 2 +- .../coding-agent/src/core/codex-fast-mode.ts | 4 ++ packages/coding-agent/src/index.ts | 11 +++++ .../components/fast-mode-selector.ts | 4 +- .../modes/interactive/components/footer.ts | 17 ++++++- .../test/fast-mode-selector.test.ts | 6 +-- .../test/footer-codex-fast-mode.test.ts | 46 +++++++++++++++++++ .../src/runs/foreground/stage-runner.ts | 30 +++++++++++- test/unit/stage-runner.test.ts | 25 ++++++++++ 11 files changed, 137 insertions(+), 12 deletions(-) create mode 100644 packages/coding-agent/test/footer-codex-fast-mode.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 075e14f5d..8b0ecb5df 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- Added `/fast` Codex fast mode toggles for chat and workflow-stage sessions, applying OpenAI priority service tier to supported `openai/*` and `openai-codex/*` models only ([#1134](https://github.com/flora131/atomic/issues/1134)). +- Added `/fast` Codex fast mode toggles for chat and workflow-stage sessions, applying OpenAI priority service tier to supported `openai/*` and `openai-codex/*` models only; active supported models now show a visible `fast` indicator after the model name ([#1134](https://github.com/flora131/atomic/issues/1134)). ## [0.8.21] - 2026-05-30 diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index 98364cc4d..6725a5599 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -28,7 +28,7 @@ Use `/logout` to clear credentials. Tokens are stored in `~/.atomic/agent/auth.j ### Codex Fast Mode -Run `/fast` in interactive mode to enable OpenAI priority service tier separately for normal chat and workflow-stage sessions. The command is shown only when the current model scope includes a supported `openai/*` or `openai-codex/*` model. Fast mode intentionally does not apply to `github-copilot/*`, Azure OpenAI, OpenRouter, or custom OpenAI-compatible providers. +Run `/fast` in interactive mode to enable OpenAI priority service tier separately for normal chat and workflow-stage sessions. The command is shown only when the current model scope includes a supported `openai/*` or `openai-codex/*` model. When enabled for the active supported model, the UI appends `fast` after the model name in the chat footer and workflow stage model labels. Fast mode intentionally does not apply to `github-copilot/*`, Azure OpenAI, OpenRouter, or custom OpenAI-compatible providers. ### Claude Pro/Max diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 0563b42db..fe711710a 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -36,7 +36,7 @@ Edit directly or use `/settings` for common options. Atomic reads legacy `~/.pi/ ### Codex Fast Mode -Use `/fast` in interactive mode to edit these settings. Atomic applies fast mode only to supported `openai/*` and `openai-codex/*` providers, not `github-copilot/*` or other OpenAI-compatible providers. +Use `/fast` in interactive mode to edit these settings. Atomic applies fast mode only to supported `openai/*` and `openai-codex/*` providers, not `github-copilot/*` or other OpenAI-compatible providers. When fast mode is active for the current supported model, Atomic shows `fast` after the model name in the chat footer and workflow stage model labels. | Setting | Type | Default | Description | |---------|------|---------|-------------| diff --git a/packages/coding-agent/src/core/codex-fast-mode.ts b/packages/coding-agent/src/core/codex-fast-mode.ts index d8242df24..21013a94e 100644 --- a/packages/coding-agent/src/core/codex-fast-mode.ts +++ b/packages/coding-agent/src/core/codex-fast-mode.ts @@ -77,3 +77,7 @@ export function withCodexFastModePayload(payload: unknown, enabled = true): unkn service_tier: CODEX_FAST_MODE_SERVICE_TIER, }; } + +export function formatCodexFastModeModelLabel(modelName: string, enabled: boolean): string { + return enabled ? `${modelName} fast` : modelName; +} diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index afdefd373..324c16e0c 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -71,6 +71,17 @@ export { serializeConversation, shouldCompact, } from "./core/compaction/index.ts"; +export { + CODEX_FAST_MODE_SERVICE_TIER, + formatCodexFastModeModelLabel, + getCodexFastModeScope, + hasSupportedCodexFastModeModel, + isCodexFastModeSupportedModel, + isCodexFastModeSupportedProvider, + shouldApplyCodexFastMode, + type CodexFastModeResolvedSettings, + type CodexFastModeScope, +} from "./core/codex-fast-mode.ts"; export { createEventBus, type EventBus, type EventBusController } from "./core/event-bus.ts"; // Extension system export type { diff --git a/packages/coding-agent/src/modes/interactive/components/fast-mode-selector.ts b/packages/coding-agent/src/modes/interactive/components/fast-mode-selector.ts index 9c37e4c90..99a365d11 100644 --- a/packages/coding-agent/src/modes/interactive/components/fast-mode-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/fast-mode-selector.ts @@ -52,11 +52,11 @@ export class FastModeSelectorComponent { return; } if (matchesKey(data, "left")) { - this.setCurrentRow(false); + this.setCurrentRow(true); return; } if (matchesKey(data, "right")) { - this.setCurrentRow(true); + this.setCurrentRow(false); return; } if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { diff --git a/packages/coding-agent/src/modes/interactive/components/footer.ts b/packages/coding-agent/src/modes/interactive/components/footer.ts index 3d5b8e347..dea033366 100644 --- a/packages/coding-agent/src/modes/interactive/components/footer.ts +++ b/packages/coding-agent/src/modes/interactive/components/footer.ts @@ -5,6 +5,10 @@ import { visibleWidth, } from "@earendil-works/pi-tui"; import type { AgentSession } from "../../../core/agent-session.ts"; +import { + formatCodexFastModeModelLabel, + shouldApplyCodexFastMode, +} from "../../../core/codex-fast-mode.ts"; import type { ReadonlyFooterDataProvider } from "../../../core/footer-data-provider.ts"; import { theme } from "../theme/theme.ts"; @@ -214,11 +218,20 @@ export class FooterComponent implements Component { const pwd = replaceHome(this.session.sessionManager.getCwd()); const modelName = state.model?.id || "no-model"; - let modelLabel = modelName; + const fastModeSettings = this.session.settingsManager?.getCodexFastModeSettings?.(); + const fastModeEnabled = state.model && fastModeSettings + ? shouldApplyCodexFastMode( + state.model, + fastModeSettings, + undefined, + ) + : false; + const fastModelName = formatCodexFastModeModelLabel(modelName, fastModeEnabled); + let modelLabel = fastModelName; if (state.model?.reasoning) { const thinkingLevel = state.thinkingLevel || "off"; modelLabel = - thinkingLevel === "off" ? modelName : `${modelName} ${thinkingLevel}`; + thinkingLevel === "off" ? fastModelName : `${fastModelName} ${thinkingLevel}`; } if (this.footerData.getAvailableProviderCount() > 1 && state.model) { modelLabel = `(${state.model.provider}) ${modelLabel}`; diff --git a/packages/coding-agent/test/fast-mode-selector.test.ts b/packages/coding-agent/test/fast-mode-selector.test.ts index e8411e9cf..a49c43413 100644 --- a/packages/coding-agent/test/fast-mode-selector.test.ts +++ b/packages/coding-agent/test/fast-mode-selector.test.ts @@ -48,16 +48,16 @@ describe("FastModeSelectorComponent", () => { { onChange, onCancel: () => {} }, ); - selector.handleInput("\x1b[C"); + selector.handleInput("\x1b[D"); expect(selector.getSettings()).toEqual({ chat: true, workflow: false }); expect(onChange).toHaveBeenLastCalledWith({ chat: true, workflow: false }); selector.handleInput("\t"); - selector.handleInput("\x1b[C"); + selector.handleInput("\x1b[D"); expect(selector.getSettings()).toEqual({ chat: true, workflow: true }); expect(onChange).toHaveBeenLastCalledWith({ chat: true, workflow: true }); - selector.handleInput("\x1b[D"); + selector.handleInput("\x1b[C"); expect(selector.getSettings()).toEqual({ chat: true, workflow: false }); expect(onChange).toHaveBeenLastCalledWith({ chat: true, workflow: false }); }); diff --git a/packages/coding-agent/test/footer-codex-fast-mode.test.ts b/packages/coding-agent/test/footer-codex-fast-mode.test.ts new file mode 100644 index 000000000..012eb019c --- /dev/null +++ b/packages/coding-agent/test/footer-codex-fast-mode.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import type { AgentSession } from "../src/core/agent-session.ts"; +import type { ReadonlyFooterDataProvider } from "../src/core/footer-data-provider.ts"; +import { FooterComponent } from "../src/modes/interactive/components/footer.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; + +function plain(line: string): string { + return line.replace(/\u001b\[[0-9;]*m/g, ""); +} + +function sessionWithFastMode(chat: boolean): AgentSession { + return { + state: { + model: { provider: "openai", id: "gpt-5.1-codex" }, + thinkingLevel: "off", + }, + settingsManager: { + getCodexFastModeSettings: () => ({ chat, workflow: false }), + }, + sessionManager: { + getCwd: () => "/tmp/project", + }, + isStreaming: false, + } as unknown as AgentSession; +} + +const footerData = { + getAvailableProviderCount: () => 1, +} as unknown as ReadonlyFooterDataProvider; + +describe("FooterComponent Codex fast mode indicator", () => { + it("shows fast after the model name when chat fast mode applies", () => { + initTheme("dark"); + const footer = new FooterComponent(sessionWithFastMode(true), footerData); + + expect(plain(footer.render(120)[0])).toContain("gpt-5.1-codex fast"); + }); + + it("omits fast when chat fast mode is disabled", () => { + initTheme("dark"); + const footer = new FooterComponent(sessionWithFastMode(false), footerData); + + expect(plain(footer.render(120)[0])).toContain("gpt-5.1-codex •"); + expect(plain(footer.render(120)[0])).not.toContain("fast"); + }); +}); diff --git a/packages/workflows/src/runs/foreground/stage-runner.ts b/packages/workflows/src/runs/foreground/stage-runner.ts index e9c58a6ef..cff795803 100644 --- a/packages/workflows/src/runs/foreground/stage-runner.ts +++ b/packages/workflows/src/runs/foreground/stage-runner.ts @@ -9,7 +9,15 @@ import { mkdir, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, resolve } from "node:path"; -import { SessionManager, type AgentSession, type CreateAgentSessionOptions, type PromptOptions } from "@bastani/atomic"; +import { + formatCodexFastModeModelLabel, + shouldApplyCodexFastMode, + SessionManager, + SettingsManager, + type AgentSession, + type CreateAgentSessionOptions, + type PromptOptions, +} from "@bastani/atomic"; import type { CompleteStageOpts, StageContext, @@ -532,6 +540,24 @@ export function createStageContext(opts: StageRunnerOpts): InternalStageContext return { ...(stageOptions ?? {}), model: candidate.value, fallbackModels: undefined }; } + function formatStageModelLabel(modelId: string | undefined): string | undefined { + if (modelId === undefined) return undefined; + const model = session?.model; + if (model === undefined) return modelId; + const settingsManager = stageOptions?.settingsManager ?? SettingsManager.create(stageOptions?.cwd ?? process.cwd(), stageOptions?.agentDir); + const fastModeEnabled = shouldApplyCodexFastMode(model, settingsManager.getCodexFastModeSettings(), { + kind: "workflow-stage", + workflowRunId: runId, + workflowStageId: stageId, + workflowStageName: stageName, + constraints: { + disableWorkflowTool: true, + maxSubagentDepth: 0, + }, + }); + return formatCodexFastModeModelLabel(modelId, fastModeEnabled); + } + function attachSession(created: StageSessionRuntime): StageSessionRuntime { session = created; if (pendingThinkingLevel !== undefined) { @@ -836,7 +862,7 @@ export function createStageContext(opts: StageRunnerOpts): InternalStageContext __modelFallbackMeta() { const attemptedModels = modelAttempts.map((attempt) => attempt.model); - const model = selectedModel ?? workflowModelId(session?.model); + const model = formatStageModelLabel(selectedModel ?? workflowModelId(session?.model)); return { ...(model !== undefined ? { model } : {}), ...(attemptedModels.length > 0 ? { attemptedModels } : {}), diff --git a/test/unit/stage-runner.test.ts b/test/unit/stage-runner.test.ts index f9ab520c2..7a83cf643 100644 --- a/test/unit/stage-runner.test.ts +++ b/test/unit/stage-runner.test.ts @@ -434,6 +434,31 @@ describe("createStageContext — model fallback", () => { assert.deepEqual(ctx.__modelFallbackMeta().modelAttempts?.map((attempt) => attempt.success), [false, true]); }); + test("workflow fast mode appends a visible fast indicator to model metadata", async () => { + const agentSession: AgentSessionAdapter = { + async create() { + const { session } = makeMockSession({ + model: { provider: "openai", id: "gpt-5.1-codex" } as AgentSession["model"], + async prompt() {}, + }); + return session; + }, + }; + + const ctx = createStageContext(makeOpts({ + adapters: { agentSession }, + stageOptions: { + settingsManager: { + getCodexFastModeSettings: () => ({ chat: false, workflow: true }), + }, + } as Parameters[0]["stageOptions"], + })) as InternalStageContext; + + await ctx.prompt("go"); + + assert.equal(ctx.__modelFallbackMeta().model, "openai/gpt-5.1-codex fast"); + }); + test("current model is appended as an implicit final fallback", async () => { const calls: string[] = []; const agentSession: AgentSessionAdapter = { From 300ffd4d9c68044735be11d02031521a435bc855 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Sat, 30 May 2026 18:56:56 -0700 Subject: [PATCH 06/16] fix(coding-agent): persist fast mode project overrides --- packages/coding-agent/CHANGELOG.md | 4 ++++ .../coding-agent/src/core/settings-manager.ts | 17 ++++++++++++++ .../settings-manager-codex-fast-mode.test.ts | 22 +++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8b0ecb5df..96b987d40 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,10 @@ - Added `/fast` Codex fast mode toggles for chat and workflow-stage sessions, applying OpenAI priority service tier to supported `openai/*` and `openai-codex/*` models only; active supported models now show a visible `fast` indicator after the model name ([#1134](https://github.com/flora131/atomic/issues/1134)). +### Fixed + +- Fixed `/fast` persistence so existing project-level fast-mode overrides are updated alongside global settings instead of masking the newly selected chat or workflow fast-mode state ([#1134](https://github.com/flora131/atomic/issues/1134)). + ## [0.8.21] - 2026-05-30 ### Changed diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index d576efad8..5bedabfa6 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -1135,6 +1135,23 @@ export class SettingsManager { this.globalSettings.codexFastMode.workflow = settings.workflow; this.markModified("codexFastMode", "chat"); this.markModified("codexFastMode", "workflow"); + + const projectCodexFastMode = this.projectSettings.codexFastMode; + const projectOverridesChat = projectCodexFastMode?.chat !== undefined; + const projectOverridesWorkflow = projectCodexFastMode?.workflow !== undefined; + if (projectOverridesChat || projectOverridesWorkflow) { + this.projectSettings.codexFastMode = { ...(projectCodexFastMode ?? {}) }; + if (projectOverridesChat) { + this.projectSettings.codexFastMode.chat = settings.chat; + this.markProjectModified("codexFastMode", "chat"); + } + if (projectOverridesWorkflow) { + this.projectSettings.codexFastMode.workflow = settings.workflow; + this.markProjectModified("codexFastMode", "workflow"); + } + this.saveProjectSettings(this.projectSettings); + } + this.save(); } } diff --git a/packages/coding-agent/test/settings-manager-codex-fast-mode.test.ts b/packages/coding-agent/test/settings-manager-codex-fast-mode.test.ts index e5706d598..7dd841245 100644 --- a/packages/coding-agent/test/settings-manager-codex-fast-mode.test.ts +++ b/packages/coding-agent/test/settings-manager-codex-fast-mode.test.ts @@ -55,4 +55,26 @@ describe("SettingsManager codexFastMode", () => { expect(manager.getCodexFastModeSettings()).toEqual({ chat: true, workflow: true }); }); + + it("updates project overrides that would otherwise mask fast mode changes", async () => { + writeFileSync( + join(agentDir, "settings.json"), + JSON.stringify({ codexFastMode: { chat: false, workflow: false } }, null, 2), + ); + mkdirSync(join(cwd, ".atomic"), { recursive: true }); + writeFileSync( + join(cwd, ".atomic", "settings.json"), + JSON.stringify({ codexFastMode: { workflow: false } }, null, 2), + ); + const manager = SettingsManager.create(cwd, agentDir); + + manager.setCodexFastModeSettings({ chat: false, workflow: true }); + await manager.flush(); + + expect(manager.getCodexFastModeSettings()).toEqual({ chat: false, workflow: true }); + const savedGlobal = JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf-8")); + const savedProject = JSON.parse(readFileSync(join(cwd, ".atomic", "settings.json"), "utf-8")); + expect(savedGlobal.codexFastMode).toEqual({ chat: false, workflow: true }); + expect(savedProject.codexFastMode).toEqual({ workflow: true }); + }); }); From e6d4c65273a6c88e0f59fee0f714452ad7e948e4 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Sat, 30 May 2026 19:07:52 -0700 Subject: [PATCH 07/16] fix(workflows): render fast mode indicator separately --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/codex-fast-mode.ts | 2 +- .../coding-agent/test/codex-fast-mode.test.ts | 1 + packages/workflows/CHANGELOG.md | 4 ++++ .../workflows/src/runs/foreground/executor.ts | 4 ++++ .../src/runs/foreground/stage-runner.ts | 17 +++++++------ packages/workflows/src/shared/store-types.ts | 4 +++- packages/workflows/src/shared/types.ts | 1 + packages/workflows/src/tui/node-card.ts | 4 ++-- test/unit/node-card.test.ts | 12 ++++++++++ test/unit/stage-runner.test.ts | 24 +++++++++++++++++-- 11 files changed, 59 insertions(+), 15 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 96b987d40..0a4ab96d5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed - Fixed `/fast` persistence so existing project-level fast-mode overrides are updated alongside global settings instead of masking the newly selected chat or workflow fast-mode state ([#1134](https://github.com/flora131/atomic/issues/1134)). +- Made the Codex fast-mode payload helper safe-by-default when called without an explicit enabled flag ([#1134](https://github.com/flora131/atomic/issues/1134)). ## [0.8.21] - 2026-05-30 diff --git a/packages/coding-agent/src/core/codex-fast-mode.ts b/packages/coding-agent/src/core/codex-fast-mode.ts index 21013a94e..90672bcf6 100644 --- a/packages/coding-agent/src/core/codex-fast-mode.ts +++ b/packages/coding-agent/src/core/codex-fast-mode.ts @@ -67,7 +67,7 @@ function isObjectPayload(payload: unknown): payload is Record { return typeof payload === "object" && payload !== null && !Array.isArray(payload); } -export function withCodexFastModePayload(payload: unknown, enabled = true): unknown { +export function withCodexFastModePayload(payload: unknown, enabled = false): unknown { if (!enabled || !isObjectPayload(payload) || "service_tier" in payload) { return payload; } diff --git a/packages/coding-agent/test/codex-fast-mode.test.ts b/packages/coding-agent/test/codex-fast-mode.test.ts index 370e687c5..3fa5aa05b 100644 --- a/packages/coding-agent/test/codex-fast-mode.test.ts +++ b/packages/coding-agent/test/codex-fast-mode.test.ts @@ -60,6 +60,7 @@ describe("codex fast mode helpers", () => { it("adds service_tier to object payloads without overwriting existing values", () => { expect(withCodexFastModePayload("not-object", true)).toBe("not-object"); expect(withCodexFastModePayload(["array"], true)).toEqual(["array"]); + expect(withCodexFastModePayload({ model: "gpt" })).toEqual({ model: "gpt" }); expect(withCodexFastModePayload({ model: "gpt" }, false)).toEqual({ model: "gpt" }); expect(withCodexFastModePayload({ model: "gpt" }, true)).toEqual({ model: "gpt", diff --git a/packages/workflows/CHANGELOG.md b/packages/workflows/CHANGELOG.md index d76492017..03e305235 100644 --- a/packages/workflows/CHANGELOG.md +++ b/packages/workflows/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Fixed + +- Kept workflow stage model metadata as the raw model id while surfacing Codex fast mode as a separate visible `fast` marker on workflow node cards. + ## [0.8.21] - 2026-05-30 ### Changed diff --git a/packages/workflows/src/runs/foreground/executor.ts b/packages/workflows/src/runs/foreground/executor.ts index 1af221208..f70bb57b1 100644 --- a/packages/workflows/src/runs/foreground/executor.ts +++ b/packages/workflows/src/runs/foreground/executor.ts @@ -2175,6 +2175,7 @@ export async function run>( __pendingMessageCount: () => 0, __modelFallbackMeta: () => ({ ...(replaySource.model !== undefined ? { model: replaySource.model } : {}), + ...(replaySource.fastMode === true ? { fastMode: replaySource.fastMode } : {}), ...(replaySource.attemptedModels !== undefined ? { attemptedModels: replaySource.attemptedModels } : {}), ...(replaySource.modelAttempts !== undefined ? { modelAttempts: replaySource.modelAttempts } : {}), }), @@ -2354,6 +2355,7 @@ export async function run>( const finalModelMeta = innerCtx.__modelFallbackMeta(); if (finalModelMeta.model !== undefined) stageSnapshot.model = finalModelMeta.model; + if (finalModelMeta.fastMode === true) stageSnapshot.fastMode = finalModelMeta.fastMode; if (finalModelMeta.attemptedModels !== undefined) stageSnapshot.attemptedModels = finalModelMeta.attemptedModels; if (finalModelMeta.modelAttempts !== undefined) stageSnapshot.modelAttempts = finalModelMeta.modelAttempts; @@ -2558,6 +2560,7 @@ export async function run>( } const modelMeta = innerCtx.__modelFallbackMeta(); if (modelMeta.model !== undefined) stageSnapshot.model = modelMeta.model; + if (modelMeta.fastMode === true) stageSnapshot.fastMode = modelMeta.fastMode; if (modelMeta.attemptedModels !== undefined) stageSnapshot.attemptedModels = modelMeta.attemptedModels; if (modelMeta.modelAttempts !== undefined) stageSnapshot.modelAttempts = modelMeta.modelAttempts; } @@ -2706,6 +2709,7 @@ export async function run>( ...(sessionId !== undefined ? { sessionId } : {}), ...(stage.sessionFile !== undefined ? { sessionFile: stage.sessionFile } : {}), ...(stageMeta.model !== undefined ? { model: stageMeta.model } : {}), + ...(stageMeta.fastMode === true ? { fastMode: stageMeta.fastMode } : {}), ...(stageMeta.attemptedModels !== undefined ? { attemptedModels: stageMeta.attemptedModels } : {}), ...(stageMeta.modelAttempts !== undefined ? { modelAttempts: stageMeta.modelAttempts } : {}), ...(stageMeta.warnings !== undefined ? { warnings: stageMeta.warnings } : {}), diff --git a/packages/workflows/src/runs/foreground/stage-runner.ts b/packages/workflows/src/runs/foreground/stage-runner.ts index cff795803..d71f4ea2d 100644 --- a/packages/workflows/src/runs/foreground/stage-runner.ts +++ b/packages/workflows/src/runs/foreground/stage-runner.ts @@ -10,10 +10,8 @@ import { mkdir, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, resolve } from "node:path"; import { - formatCodexFastModeModelLabel, shouldApplyCodexFastMode, SessionManager, - SettingsManager, type AgentSession, type CreateAgentSessionOptions, type PromptOptions, @@ -71,6 +69,7 @@ export interface AgentSessionAdapter { export interface StageModelFallbackMeta { readonly model?: string; + readonly fastMode?: boolean; readonly attemptedModels?: readonly string[]; readonly modelAttempts?: readonly WorkflowModelAttempt[]; readonly warnings?: readonly string[]; @@ -540,12 +539,11 @@ export function createStageContext(opts: StageRunnerOpts): InternalStageContext return { ...(stageOptions ?? {}), model: candidate.value, fallbackModels: undefined }; } - function formatStageModelLabel(modelId: string | undefined): string | undefined { - if (modelId === undefined) return undefined; + function isWorkflowFastModeEnabled(): boolean | undefined { const model = session?.model; - if (model === undefined) return modelId; - const settingsManager = stageOptions?.settingsManager ?? SettingsManager.create(stageOptions?.cwd ?? process.cwd(), stageOptions?.agentDir); - const fastModeEnabled = shouldApplyCodexFastMode(model, settingsManager.getCodexFastModeSettings(), { + const settingsManager = stageOptions?.settingsManager; + if (model === undefined || settingsManager === undefined) return undefined; + return shouldApplyCodexFastMode(model, settingsManager.getCodexFastModeSettings(), { kind: "workflow-stage", workflowRunId: runId, workflowStageId: stageId, @@ -555,7 +553,6 @@ export function createStageContext(opts: StageRunnerOpts): InternalStageContext maxSubagentDepth: 0, }, }); - return formatCodexFastModeModelLabel(modelId, fastModeEnabled); } function attachSession(created: StageSessionRuntime): StageSessionRuntime { @@ -862,9 +859,11 @@ export function createStageContext(opts: StageRunnerOpts): InternalStageContext __modelFallbackMeta() { const attemptedModels = modelAttempts.map((attempt) => attempt.model); - const model = formatStageModelLabel(selectedModel ?? workflowModelId(session?.model)); + const model = selectedModel ?? workflowModelId(session?.model); + const fastMode = isWorkflowFastModeEnabled(); return { ...(model !== undefined ? { model } : {}), + ...(fastMode === true ? { fastMode } : {}), ...(attemptedModels.length > 0 ? { attemptedModels } : {}), ...(modelAttempts.length > 0 ? { modelAttempts: [...modelAttempts] } : {}), ...(modelWarnings.length > 0 ? { warnings: [...modelWarnings] } : {}), diff --git a/packages/workflows/src/shared/store-types.ts b/packages/workflows/src/shared/store-types.ts index 143c20930..93c00c14c 100644 --- a/packages/workflows/src/shared/store-types.ts +++ b/packages/workflows/src/shared/store-types.ts @@ -155,8 +155,10 @@ export interface StageSnapshot { */ sessionId?: string; sessionFile?: string; - /** Effective model selected for this stage after fallback resolution. */ + /** Effective model id selected for this stage after fallback resolution. */ model?: string; + /** True when Codex fast mode applied to this workflow stage. */ + fastMode?: boolean; /** Ordered model ids attempted by fallback orchestration. */ attemptedModels?: readonly string[]; /** Per-model fallback attempt outcomes. */ diff --git a/packages/workflows/src/shared/types.ts b/packages/workflows/src/shared/types.ts index 0a4026471..1c8a6ec3a 100644 --- a/packages/workflows/src/shared/types.ts +++ b/packages/workflows/src/shared/types.ts @@ -243,6 +243,7 @@ export interface WorkflowTaskResult extends WorkflowTaskContext { readonly sessionFile?: string; readonly artifacts?: WorkflowArtifact[]; readonly model?: string; + readonly fastMode?: boolean; readonly attemptedModels?: readonly string[]; readonly modelAttempts?: readonly WorkflowModelAttempt[]; readonly warnings?: readonly string[]; diff --git a/packages/workflows/src/tui/node-card.ts b/packages/workflows/src/tui/node-card.ts index c8bf36f5a..3b092ddb1 100644 --- a/packages/workflows/src/tui/node-card.ts +++ b/packages/workflows/src/tui/node-card.ts @@ -120,8 +120,8 @@ function durationText(stage: StageSnapshot): string { function metaText(stage: StageSnapshot): string { const deps = stage.parentIds.length; - if (deps === 0) return "root"; - return deps === 1 ? "1 dep" : `${deps} deps`; + const dependencyText = deps === 0 ? "root" : deps === 1 ? "1 dep" : `${deps} deps`; + return stage.fastMode === true ? `${dependencyText} · fast` : dependencyText; } function statusLabel(status: StageStatus): string { diff --git a/test/unit/node-card.test.ts b/test/unit/node-card.test.ts index 8f42b23dd..26bd910b4 100644 --- a/test/unit/node-card.test.ts +++ b/test/unit/node-card.test.ts @@ -43,6 +43,7 @@ function makeStage(opts: Partial = {}): StageSnapshot { resumedAt: opts.resumedAt, blockedByStageId: opts.blockedByStageId, model: opts.model, + fastMode: opts.fastMode, }; } @@ -298,6 +299,17 @@ describe("renderNodeCard — metadata line", () => { assert.doesNotMatch(rendered, /gpt-5-mini/); assert.match(stripAnsi(lines[3]!), /1 dep/); }); + + test("stages show a visible fast marker without mutating model metadata", () => { + const lines = renderNodeCard( + makeStage({ status: "completed", model: "openai/gpt-5.1-codex", fastMode: true }), + { theme }, + ); + const rendered = stripAnsi(lines.join("\n")); + + assert.doesNotMatch(rendered, /openai\/gpt-5\.1-codex fast/); + assert.match(stripAnsi(lines[3]!), /root · fast/); + }); }); describe("renderNodeCard — duration line", () => { diff --git a/test/unit/stage-runner.test.ts b/test/unit/stage-runner.test.ts index 7a83cf643..626619a01 100644 --- a/test/unit/stage-runner.test.ts +++ b/test/unit/stage-runner.test.ts @@ -434,7 +434,7 @@ describe("createStageContext — model fallback", () => { assert.deepEqual(ctx.__modelFallbackMeta().modelAttempts?.map((attempt) => attempt.success), [false, true]); }); - test("workflow fast mode appends a visible fast indicator to model metadata", async () => { + test("workflow fast mode keeps raw model metadata with a structured fast flag", async () => { const agentSession: AgentSessionAdapter = { async create() { const { session } = makeMockSession({ @@ -456,7 +456,27 @@ describe("createStageContext — model fallback", () => { await ctx.prompt("go"); - assert.equal(ctx.__modelFallbackMeta().model, "openai/gpt-5.1-codex fast"); + assert.equal(ctx.__modelFallbackMeta().model, "openai/gpt-5.1-codex"); + assert.equal(ctx.__modelFallbackMeta().fastMode, true); + }); + + test("workflow fast mode metadata does not reload settings when no manager is provided", async () => { + const agentSession: AgentSessionAdapter = { + async create() { + const { session } = makeMockSession({ + model: { provider: "openai", id: "gpt-5.1-codex" } as AgentSession["model"], + async prompt() {}, + }); + return session; + }, + }; + + const ctx = createStageContext(makeOpts({ adapters: { agentSession } })) as InternalStageContext; + + await ctx.prompt("go"); + + assert.equal(ctx.__modelFallbackMeta().model, "openai/gpt-5.1-codex"); + assert.equal(ctx.__modelFallbackMeta().fastMode, undefined); }); test("current model is appended as an implicit final fallback", async () => { From 3d2eda78317c65e7162d24d1db7cea1cbc310ac3 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Sat, 30 May 2026 19:16:31 -0700 Subject: [PATCH 08/16] fix(workflows): propagate fast mode metadata settings --- packages/workflows/CHANGELOG.md | 2 +- packages/workflows/src/extension/wiring.ts | 25 +++++++----- .../src/runs/foreground/stage-runner.ts | 40 +++++++++++++++---- test/unit/stage-runner.test.ts | 24 +++++++++++ test/unit/wiring-adapters.test.ts | 21 ++++++++-- 5 files changed, 91 insertions(+), 21 deletions(-) diff --git a/packages/workflows/CHANGELOG.md b/packages/workflows/CHANGELOG.md index 03e305235..00bcf89f2 100644 --- a/packages/workflows/CHANGELOG.md +++ b/packages/workflows/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed -- Kept workflow stage model metadata as the raw model id while surfacing Codex fast mode as a separate visible `fast` marker on workflow node cards. +- Kept workflow stage model metadata as the raw model id while surfacing Codex fast mode as a separate visible `fast` marker on workflow node cards, including stages that use the default Atomic SDK adapter settings manager. ## [0.8.21] - 2026-05-30 diff --git a/packages/workflows/src/extension/wiring.ts b/packages/workflows/src/extension/wiring.ts index 54e9462cb..fe01bfb82 100644 --- a/packages/workflows/src/extension/wiring.ts +++ b/packages/workflows/src/extension/wiring.ts @@ -23,7 +23,7 @@ import { basename } from "node:path"; import type { ChatMessageRenderOptions, CreateAgentSessionOptions } from "@bastani/atomic"; -import type { StageAdapters, StageSessionRuntime } from "../runs/foreground/stage-runner.js"; +import type { StageAdapters, StageSessionCreateResult, StageSessionRuntime } from "../runs/foreground/stage-runner.js"; import type { StageExecutionMeta, StageOptions } from "../shared/types.js"; import { stageUiBroker, type StageUiBroker } from "../shared/stage-ui-broker.js"; @@ -56,12 +56,12 @@ export interface RuntimeWiringSurface { exec?: (command: string, args: string[], opts?: PiExecOpts) => Promise; ui?: PiUISurface; /** Test seam: inject a stub session factory instead of importing the SDK. */ - createAgentSession?: (options?: CreateAgentSessionOptions) => Promise<{ session: StageSessionRuntime }>; + createAgentSession?: (options?: CreateAgentSessionOptions) => Promise; } export interface RuntimeAdapterBuildOptions { /** Test seam for SDK session creation. */ - createAgentSession?: (options?: CreateAgentSessionOptions) => Promise<{ session: StageSessionRuntime }>; + createAgentSession?: (options?: CreateAgentSessionOptions) => Promise; /** Broker that routes stage-local custom UI into attached workflow nodes. */ stageUiBroker?: StageUiBroker; } @@ -90,7 +90,9 @@ function isTestContext(): boolean { * cross-ref: node_modules/@bastani/atomic/docs/sdk.md * node_modules/@bastani/atomic/dist/core/sdk.d.ts */ -export interface PiSdkSettingsManager {} +export interface PiSdkSettingsManager { + getCodexFastModeSettings(): { readonly chat: boolean; readonly workflow: boolean }; +} export interface PiSdkResourceLoader { reload(): Promise; } @@ -176,17 +178,22 @@ function stageBuiltinPackagePaths(paths: readonly string[]): string[] { async function createPiSdkAgentSession( options?: CreateAgentSessionOptions, -): Promise<{ session: StageSessionRuntime }> { +): Promise { const sdk = await import("@bastani/atomic") as PiCodingAgentSdk; const sessionOptions = await prepareAtomicStageSessionOptions(options, sdk); const result = await sdk.createAgentSession(sessionOptions); // `CreateAgentSessionResult` is `{ session, extensionsResult, modelFallbackMessage? }`; // workflow stages only consume `.session` (structurally an `AgentSession`, // which is a superset of our `StageSessionRuntime` projection). - return { session: result.session }; + return { + session: result.session, + ...(sessionOptions?.settingsManager?.getCodexFastModeSettings !== undefined + ? { settingsManager: sessionOptions.settingsManager } + : {}), + }; } -async function createTestAgentSession(_options?: CreateAgentSessionOptions): Promise<{ session: StageSessionRuntime }> { +async function createTestAgentSession(_options?: CreateAgentSessionOptions): Promise { let lastAssistantText: string | undefined; const session: StageSessionRuntime = { async prompt(text: string): Promise { @@ -343,7 +350,7 @@ export function buildRuntimeAdapters( const broker = options.stageUiBroker ?? stageUiBroker; const adapters: StageAdapters = { agentSession: { - async create(stageOptions: CreateAgentSessionOptions & Pick, meta?: StageExecutionMeta): Promise { + async create(stageOptions: CreateAgentSessionOptions & Pick, meta?: StageExecutionMeta): Promise { // Atomic's SDK handles extension / skills / prompt-template / // slash-command discovery via the SettingsManager / ResourceLoader. // The production default deliberately uses normal DefaultResourceLoader @@ -362,7 +369,7 @@ export function buildRuntimeAdapters( uiContext: makeStageExtensionUiContext(pi.ui ?? {}, meta, broker), }); } - return result.session; + return result; }, }, }; diff --git a/packages/workflows/src/runs/foreground/stage-runner.ts b/packages/workflows/src/runs/foreground/stage-runner.ts index d71f4ea2d..95ce506ef 100644 --- a/packages/workflows/src/runs/foreground/stage-runner.ts +++ b/packages/workflows/src/runs/foreground/stage-runner.ts @@ -63,8 +63,22 @@ export interface StageSessionRuntime { export type StageSessionCreateOptions = CreateAgentSessionOptions & Pick; +type WorkflowFastModeSettings = { + readonly chat: boolean; + readonly workflow: boolean; +}; + +type WorkflowFastModeSettingsManager = { + getCodexFastModeSettings(): WorkflowFastModeSettings; +}; + +export interface StageSessionCreateResult { + readonly session: StageSessionRuntime; + readonly settingsManager?: WorkflowFastModeSettingsManager; +} + export interface AgentSessionAdapter { - create(options: StageSessionCreateOptions, meta?: StageExecutionMeta): Promise; + create(options: StageSessionCreateOptions, meta?: StageExecutionMeta): Promise; } export interface StageModelFallbackMeta { @@ -539,9 +553,11 @@ export function createStageContext(opts: StageRunnerOpts): InternalStageContext return { ...(stageOptions ?? {}), model: candidate.value, fallbackModels: undefined }; } + let sessionSettingsManager: WorkflowFastModeSettingsManager | undefined; + function isWorkflowFastModeEnabled(): boolean | undefined { const model = session?.model; - const settingsManager = stageOptions?.settingsManager; + const settingsManager = sessionSettingsManager ?? stageOptions?.settingsManager; if (model === undefined || settingsManager === undefined) return undefined; return shouldApplyCodexFastMode(model, settingsManager.getCodexFastModeSettings(), { kind: "workflow-stage", @@ -555,19 +571,26 @@ export function createStageContext(opts: StageRunnerOpts): InternalStageContext }); } - function attachSession(created: StageSessionRuntime): StageSessionRuntime { - session = created; + function normalizeSessionCreateResult(created: StageSessionRuntime | StageSessionCreateResult): StageSessionCreateResult { + if ("session" in created) return created; + return { session: created }; + } + + function attachSession(created: StageSessionRuntime | StageSessionCreateResult): StageSessionRuntime { + const result = normalizeSessionCreateResult(created); + session = result.session; + sessionSettingsManager = result.settingsManager; if (pendingThinkingLevel !== undefined) { - created.setThinkingLevel(pendingThinkingLevel); + result.session.setThinkingLevel(pendingThinkingLevel); } for (const listener of pendingListeners) { - listenerUnsubscribes.set(listener, created.subscribe(listener)); + listenerUnsubscribes.set(listener, result.session.subscribe(listener)); } // Track terminating tool calls for this session so the stage result text is // derived deterministically from a tool that actually ended the turn. unsubscribeTerminateWatcher?.(); - unsubscribeTerminateWatcher = created.subscribe((event) => recordTerminatingToolCall(event)); - return created; + unsubscribeTerminateWatcher = result.session.subscribe((event) => recordTerminatingToolCall(event)); + return result.session; } async function createSession( @@ -603,6 +626,7 @@ export function createStageContext(opts: StageRunnerOpts): InternalStageContext const current = session; session = undefined; sessionPromise = undefined; + sessionSettingsManager = undefined; for (const unsubscribe of listenerUnsubscribes.values()) unsubscribe(); listenerUnsubscribes.clear(); unsubscribeTerminateWatcher?.(); diff --git a/test/unit/stage-runner.test.ts b/test/unit/stage-runner.test.ts index 626619a01..1edfa8bae 100644 --- a/test/unit/stage-runner.test.ts +++ b/test/unit/stage-runner.test.ts @@ -460,6 +460,30 @@ describe("createStageContext — model fallback", () => { assert.equal(ctx.__modelFallbackMeta().fastMode, true); }); + test("workflow fast mode metadata uses the adapter-created settings manager", async () => { + const agentSession: AgentSessionAdapter = { + async create() { + const { session } = makeMockSession({ + model: { provider: "openai", id: "gpt-5.1-codex" } as AgentSession["model"], + async prompt() {}, + }); + return { + session, + settingsManager: { + getCodexFastModeSettings: () => ({ chat: false, workflow: true }), + }, + }; + }, + }; + + const ctx = createStageContext(makeOpts({ adapters: { agentSession } })) as InternalStageContext; + + await ctx.prompt("go"); + + assert.equal(ctx.__modelFallbackMeta().model, "openai/gpt-5.1-codex"); + assert.equal(ctx.__modelFallbackMeta().fastMode, true); + }); + test("workflow fast mode metadata does not reload settings when no manager is provided", async () => { const agentSession: AgentSessionAdapter = { async create() { diff --git a/test/unit/wiring-adapters.test.ts b/test/unit/wiring-adapters.test.ts index a3b42d8c2..788a73444 100644 --- a/test/unit/wiring-adapters.test.ts +++ b/test/unit/wiring-adapters.test.ts @@ -73,7 +73,9 @@ function makeFakeAtomicSdk(defaultAgentDir: string, builtinPackagePaths: string[ SettingsManager: { create(cwd?: string, agentDir?: string): PiSdkSettingsManager { settingsCalls.push({ cwd, agentDir }); - return { cwd, agentDir } as PiSdkSettingsManager; + return { + getCodexFastModeSettings: () => ({ chat: false, workflow: false }), + }; }, }, DefaultResourceLoader: FakeResourceLoader, @@ -181,11 +183,24 @@ describe("buildRuntimeAdapters — SDK AgentSession adapter", () => { const adapters = buildRuntimeAdapters({}, { createAgentSession: async (options) => { calls.push(options); return { session: fakeSession() }; }, }); - const session = await adapters.agentSession!.create({ cwd: "/tmp/project" }); - assert.equal(session.sessionId, "session-1"); + const result = await adapters.agentSession!.create({ cwd: "/tmp/project" }); + assert.equal("session" in result ? result.session.sessionId : result.sessionId, "session-1"); assert.equal(calls[0]?.cwd, "/tmp/project"); }); + test("agentSession.create returns the SDK-prepared settings manager for workflow metadata", async () => { + const settingsManager = { + getCodexFastModeSettings: () => ({ chat: false, workflow: true }), + }; + const adapters = buildRuntimeAdapters({}, { + createAgentSession: async () => ({ session: fakeSession(), settingsManager }), + }); + + const result = await adapters.agentSession!.create({ cwd: "/tmp/project" }); + + assert.equal("session" in result ? result.settingsManager : undefined, settingsManager); + }); + test("agentSession.create marks workflow stages with orchestration constraints and excludes workflow tool", async () => { const calls: Array = []; const adapters = buildRuntimeAdapters({}, { From 21330785d653ef63e148826bf80f949f14d38160 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Sat, 30 May 2026 19:26:31 -0700 Subject: [PATCH 09/16] fix(coding-agent): tighten fast mode follow-ups --- packages/coding-agent/CHANGELOG.md | 2 +- .../coding-agent/src/core/codex-fast-mode.ts | 23 +++++++++++++++---- packages/coding-agent/src/index.ts | 2 ++ .../modes/interactive/components/footer.ts | 4 ++-- .../src/modes/interactive/interactive-mode.ts | 6 +++-- .../coding-agent/test/codex-fast-mode.test.ts | 10 +++++++- .../src/runs/foreground/stage-runner.ts | 13 ++--------- test/unit/stage-chat-view.test.ts | 3 +++ 8 files changed, 42 insertions(+), 21 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0a4ab96d5..6da0933d5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,7 +9,7 @@ ### Fixed - Fixed `/fast` persistence so existing project-level fast-mode overrides are updated alongside global settings instead of masking the newly selected chat or workflow fast-mode state ([#1134](https://github.com/flora131/atomic/issues/1134)). -- Made the Codex fast-mode payload helper safe-by-default when called without an explicit enabled flag ([#1134](https://github.com/flora131/atomic/issues/1134)). +- Made Codex fast-mode request helpers require an explicit enabled flag and treat `service_tier: undefined` as unset when preparing OpenAI payloads ([#1134](https://github.com/flora131/atomic/issues/1134)). ## [0.8.21] - 2026-05-30 diff --git a/packages/coding-agent/src/core/codex-fast-mode.ts b/packages/coding-agent/src/core/codex-fast-mode.ts index 90672bcf6..b9dc9a82c 100644 --- a/packages/coding-agent/src/core/codex-fast-mode.ts +++ b/packages/coding-agent/src/core/codex-fast-mode.ts @@ -34,11 +34,26 @@ export function getCodexFastModeScope(context: OrchestrationContext | undefined) return isWorkflowStageOrchestrationContext(context) ? "workflow" : "chat"; } +export function isCodexFastModeEnabledForScope( + settings: CodexFastModeResolvedSettings, + scope: CodexFastModeScope, +): boolean { + return settings[scope]; +} + export function isCodexFastModeEnabledForSession( settings: CodexFastModeResolvedSettings, context: OrchestrationContext | undefined, ): boolean { - return settings[getCodexFastModeScope(context)]; + return isCodexFastModeEnabledForScope(settings, getCodexFastModeScope(context)); +} + +export function shouldApplyCodexFastModeForScope( + model: Pick, "provider">, + settings: CodexFastModeResolvedSettings, + scope: CodexFastModeScope, +): boolean { + return isCodexFastModeSupportedModel(model) && isCodexFastModeEnabledForScope(settings, scope); } export function shouldApplyCodexFastMode( @@ -46,7 +61,7 @@ export function shouldApplyCodexFastMode( settings: CodexFastModeResolvedSettings, context: OrchestrationContext | undefined, ): boolean { - return isCodexFastModeSupportedModel(model) && isCodexFastModeEnabledForSession(settings, context); + return shouldApplyCodexFastModeForScope(model, settings, getCodexFastModeScope(context)); } export function withCodexFastModeStreamOptions( @@ -67,8 +82,8 @@ function isObjectPayload(payload: unknown): payload is Record { return typeof payload === "object" && payload !== null && !Array.isArray(payload); } -export function withCodexFastModePayload(payload: unknown, enabled = false): unknown { - if (!enabled || !isObjectPayload(payload) || "service_tier" in payload) { +export function withCodexFastModePayload(payload: unknown, enabled: boolean): unknown { + if (!enabled || !isObjectPayload(payload) || payload.service_tier !== undefined) { return payload; } diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 324c16e0c..4cddc863e 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -76,9 +76,11 @@ export { formatCodexFastModeModelLabel, getCodexFastModeScope, hasSupportedCodexFastModeModel, + isCodexFastModeEnabledForScope, isCodexFastModeSupportedModel, isCodexFastModeSupportedProvider, shouldApplyCodexFastMode, + shouldApplyCodexFastModeForScope, type CodexFastModeResolvedSettings, type CodexFastModeScope, } from "./core/codex-fast-mode.ts"; diff --git a/packages/coding-agent/src/modes/interactive/components/footer.ts b/packages/coding-agent/src/modes/interactive/components/footer.ts index dea033366..12a8ac026 100644 --- a/packages/coding-agent/src/modes/interactive/components/footer.ts +++ b/packages/coding-agent/src/modes/interactive/components/footer.ts @@ -218,8 +218,8 @@ export class FooterComponent implements Component { const pwd = replaceHome(this.session.sessionManager.getCwd()); const modelName = state.model?.id || "no-model"; - const fastModeSettings = this.session.settingsManager?.getCodexFastModeSettings?.(); - const fastModeEnabled = state.model && fastModeSettings + const fastModeSettings = this.session.settingsManager.getCodexFastModeSettings(); + const fastModeEnabled = state.model ? shouldApplyCodexFastMode( state.model, fastModeSettings, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 862964b8f..5fe6da3a2 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -640,7 +640,10 @@ export class InteractiveMode { }), ); - // Convert extension commands to SlashCommand format + // Convert extension commands to SlashCommand format. Built-in command names + // stay reserved even when a built-in is contextually hidden (for example, + // /fast without a supported OpenAI model) so extension visibility cannot + // change as auth/model state changes. const extensionCommands: SlashCommand[] = this.session.extensionRunner .getRegisteredCommands() .filter((cmd) => !BUILTIN_SLASH_COMMAND_NAMES.has(cmd.name)) @@ -4471,7 +4474,6 @@ export class InteractiveMode { { onChange: (settings) => { this.settingsManager.setCodexFastModeSettings(settings); - void this.settingsManager.flush(); this.showStatus( `Codex fast mode: chat ${settings.chat ? "enabled" : "disabled"}, workflow ${settings.workflow ? "enabled" : "disabled"}`, ); diff --git a/packages/coding-agent/test/codex-fast-mode.test.ts b/packages/coding-agent/test/codex-fast-mode.test.ts index 3fa5aa05b..b9af06b96 100644 --- a/packages/coding-agent/test/codex-fast-mode.test.ts +++ b/packages/coding-agent/test/codex-fast-mode.test.ts @@ -4,8 +4,10 @@ import { CODEX_FAST_MODE_SERVICE_TIER, getCodexFastModeScope, hasSupportedCodexFastModeModel, + isCodexFastModeEnabledForScope, isCodexFastModeEnabledForSession, isCodexFastModeSupportedProvider, + shouldApplyCodexFastModeForScope, withCodexFastModePayload, withCodexFastModeStreamOptions, } from "../src/core/codex-fast-mode.ts"; @@ -43,9 +45,13 @@ describe("codex fast mode helpers", () => { it("selects chat versus workflow scope from orchestration context", () => { expect(getCodexFastModeScope(undefined)).toBe("chat"); expect(getCodexFastModeScope(workflowContext)).toBe("workflow"); + expect(isCodexFastModeEnabledForScope({ chat: true, workflow: false }, "chat")).toBe(true); + expect(isCodexFastModeEnabledForScope({ chat: true, workflow: false }, "workflow")).toBe(false); expect(isCodexFastModeEnabledForSession({ chat: true, workflow: false }, undefined)).toBe(true); expect(isCodexFastModeEnabledForSession({ chat: true, workflow: false }, workflowContext)).toBe(false); expect(isCodexFastModeEnabledForSession({ chat: false, workflow: true }, workflowContext)).toBe(true); + expect(shouldApplyCodexFastModeForScope(model("openai"), { chat: false, workflow: true }, "workflow")).toBe(true); + expect(shouldApplyCodexFastModeForScope(model("github-copilot"), { chat: false, workflow: true }, "workflow")).toBe(false); }); it("adds serviceTier to stream options only when enabled", () => { @@ -60,12 +66,14 @@ describe("codex fast mode helpers", () => { it("adds service_tier to object payloads without overwriting existing values", () => { expect(withCodexFastModePayload("not-object", true)).toBe("not-object"); expect(withCodexFastModePayload(["array"], true)).toEqual(["array"]); - expect(withCodexFastModePayload({ model: "gpt" })).toEqual({ model: "gpt" }); expect(withCodexFastModePayload({ model: "gpt" }, false)).toEqual({ model: "gpt" }); expect(withCodexFastModePayload({ model: "gpt" }, true)).toEqual({ model: "gpt", service_tier: CODEX_FAST_MODE_SERVICE_TIER, }); expect(withCodexFastModePayload({ service_tier: "default" }, true)).toEqual({ service_tier: "default" }); + expect(withCodexFastModePayload({ service_tier: undefined }, true)).toEqual({ + service_tier: CODEX_FAST_MODE_SERVICE_TIER, + }); }); }); diff --git a/packages/workflows/src/runs/foreground/stage-runner.ts b/packages/workflows/src/runs/foreground/stage-runner.ts index 95ce506ef..adb5a02b4 100644 --- a/packages/workflows/src/runs/foreground/stage-runner.ts +++ b/packages/workflows/src/runs/foreground/stage-runner.ts @@ -10,7 +10,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, resolve } from "node:path"; import { - shouldApplyCodexFastMode, + shouldApplyCodexFastModeForScope, SessionManager, type AgentSession, type CreateAgentSessionOptions, @@ -559,16 +559,7 @@ export function createStageContext(opts: StageRunnerOpts): InternalStageContext const model = session?.model; const settingsManager = sessionSettingsManager ?? stageOptions?.settingsManager; if (model === undefined || settingsManager === undefined) return undefined; - return shouldApplyCodexFastMode(model, settingsManager.getCodexFastModeSettings(), { - kind: "workflow-stage", - workflowRunId: runId, - workflowStageId: stageId, - workflowStageName: stageName, - constraints: { - disableWorkflowTool: true, - maxSubagentDepth: 0, - }, - }); + return shouldApplyCodexFastModeForScope(model, settingsManager.getCodexFastModeSettings(), "workflow"); } function normalizeSessionCreateResult(created: StageSessionRuntime | StageSessionCreateResult): StageSessionCreateResult { diff --git a/test/unit/stage-chat-view.test.ts b/test/unit/stage-chat-view.test.ts index 3c955714a..99d94b5a3 100644 --- a/test/unit/stage-chat-view.test.ts +++ b/test/unit/stage-chat-view.test.ts @@ -162,6 +162,9 @@ function fakeFooterAgentSession(isStreaming = false): AgentSession { modelRegistry: { isUsingOAuth: () => false, }, + settingsManager: { + getCodexFastModeSettings: () => ({ chat: false, workflow: false }), + }, getContextUsage: () => ({ tokens: 46800, contextWindow: 200000, percent: 23.4 }), isStreaming, } as unknown as AgentSession; From 46ad2d3ed0bff3cded0592b7b5b695ee1f721885 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Sat, 30 May 2026 19:40:04 -0700 Subject: [PATCH 10/16] fix(workflows): surface fast mode during stage runs --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/agent-session.ts | 5 ++++ .../modes/interactive/components/footer.ts | 2 +- .../test/footer-codex-fast-mode.test.ts | 29 +++++++++++++++++-- packages/workflows/CHANGELOG.md | 1 + packages/workflows/src/extension/wiring.ts | 6 ++-- .../workflows/src/runs/foreground/executor.ts | 18 ++++++++++-- .../src/runs/foreground/stage-runner.ts | 4 ++- test/unit/stage-runner.test.ts | 22 ++++++++++++++ 9 files changed, 80 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6da0933d5..48403896a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -10,6 +10,7 @@ - Fixed `/fast` persistence so existing project-level fast-mode overrides are updated alongside global settings instead of masking the newly selected chat or workflow fast-mode state ([#1134](https://github.com/flora131/atomic/issues/1134)). - Made Codex fast-mode request helpers require an explicit enabled flag and treat `service_tier: undefined` as unset when preparing OpenAI payloads ([#1134](https://github.com/flora131/atomic/issues/1134)). +- Fixed attached workflow-stage chat footers to resolve the `fast` model indicator against workflow fast-mode settings instead of chat settings ([#1134](https://github.com/flora131/atomic/issues/1134)). ## [0.8.21] - 2026-05-30 diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 08df2660f..857185be8 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -380,6 +380,11 @@ export class AgentSession { }); } + /** Orchestration context for this session, when owned by a workflow/subagent runtime. */ + get orchestrationContext(): OrchestrationContext | undefined { + return this._orchestrationContext; + } + /** Model registry for API key resolution and model discovery */ get modelRegistry(): ModelRegistry { return this._modelRegistry; diff --git a/packages/coding-agent/src/modes/interactive/components/footer.ts b/packages/coding-agent/src/modes/interactive/components/footer.ts index 12a8ac026..caa5378a8 100644 --- a/packages/coding-agent/src/modes/interactive/components/footer.ts +++ b/packages/coding-agent/src/modes/interactive/components/footer.ts @@ -223,7 +223,7 @@ export class FooterComponent implements Component { ? shouldApplyCodexFastMode( state.model, fastModeSettings, - undefined, + this.session.orchestrationContext, ) : false; const fastModelName = formatCodexFastModeModelLabel(modelName, fastModeEnabled); diff --git a/packages/coding-agent/test/footer-codex-fast-mode.test.ts b/packages/coding-agent/test/footer-codex-fast-mode.test.ts index 012eb019c..13bacbe2c 100644 --- a/packages/coding-agent/test/footer-codex-fast-mode.test.ts +++ b/packages/coding-agent/test/footer-codex-fast-mode.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { AgentSession } from "../src/core/agent-session.ts"; +import type { OrchestrationContext } from "../src/core/extensions/types.ts"; import type { ReadonlyFooterDataProvider } from "../src/core/footer-data-provider.ts"; import { FooterComponent } from "../src/modes/interactive/components/footer.ts"; import { initTheme } from "../src/modes/interactive/theme/theme.ts"; @@ -8,15 +9,24 @@ function plain(line: string): string { return line.replace(/\u001b\[[0-9;]*m/g, ""); } -function sessionWithFastMode(chat: boolean): AgentSession { +const workflowContext: OrchestrationContext = { + kind: "workflow-stage", + workflowRunId: "run-1", + workflowStageId: "stage-1", + workflowStageName: "Stage 1", + constraints: { disableWorkflowTool: true, maxSubagentDepth: 0 }, +}; + +function sessionWithFastMode(chat: boolean, workflow = false, orchestrationContext?: OrchestrationContext): AgentSession { return { state: { model: { provider: "openai", id: "gpt-5.1-codex" }, thinkingLevel: "off", }, settingsManager: { - getCodexFastModeSettings: () => ({ chat, workflow: false }), + getCodexFastModeSettings: () => ({ chat, workflow }), }, + orchestrationContext, sessionManager: { getCwd: () => "/tmp/project", }, @@ -43,4 +53,19 @@ describe("FooterComponent Codex fast mode indicator", () => { expect(plain(footer.render(120)[0])).toContain("gpt-5.1-codex •"); expect(plain(footer.render(120)[0])).not.toContain("fast"); }); + + it("uses workflow scope for workflow-stage session footers", () => { + initTheme("dark"); + const footer = new FooterComponent(sessionWithFastMode(false, true, workflowContext), footerData); + + expect(plain(footer.render(120)[0])).toContain("gpt-5.1-codex fast"); + }); + + it("does not use chat scope for workflow-stage session footers", () => { + initTheme("dark"); + const footer = new FooterComponent(sessionWithFastMode(true, false, workflowContext), footerData); + + expect(plain(footer.render(120)[0])).toContain("gpt-5.1-codex •"); + expect(plain(footer.render(120)[0])).not.toContain("fast"); + }); }); diff --git a/packages/workflows/CHANGELOG.md b/packages/workflows/CHANGELOG.md index 00bcf89f2..b3b9118d4 100644 --- a/packages/workflows/CHANGELOG.md +++ b/packages/workflows/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed - Kept workflow stage model metadata as the raw model id while surfacing Codex fast mode as a separate visible `fast` marker on workflow node cards, including stages that use the default Atomic SDK adapter settings manager. +- Show workflow Codex fast-mode metadata on running workflow nodes as soon as the stage session starts, including custom resource-loader stages whose settings manager is created by the Atomic SDK. ## [0.8.21] - 2026-05-30 diff --git a/packages/workflows/src/extension/wiring.ts b/packages/workflows/src/extension/wiring.ts index fe01bfb82..89e66a6e3 100644 --- a/packages/workflows/src/extension/wiring.ts +++ b/packages/workflows/src/extension/wiring.ts @@ -185,10 +185,12 @@ async function createPiSdkAgentSession( // `CreateAgentSessionResult` is `{ session, extensionsResult, modelFallbackMessage? }`; // workflow stages only consume `.session` (structurally an `AgentSession`, // which is a superset of our `StageSessionRuntime` projection). + const resultSettingsManager = result.session.settingsManager; + const settingsManager = sessionOptions?.settingsManager ?? resultSettingsManager; return { session: result.session, - ...(sessionOptions?.settingsManager?.getCodexFastModeSettings !== undefined - ? { settingsManager: sessionOptions.settingsManager } + ...(settingsManager?.getCodexFastModeSettings !== undefined + ? { settingsManager } : {}), }; } diff --git a/packages/workflows/src/runs/foreground/executor.ts b/packages/workflows/src/runs/foreground/executor.ts index f70bb57b1..21cd89ec2 100644 --- a/packages/workflows/src/runs/foreground/executor.ts +++ b/packages/workflows/src/runs/foreground/executor.ts @@ -2455,7 +2455,7 @@ export async function run>( } }; - const runTrackedStageCall = async (call: () => Promise): Promise => { + const runTrackedStageCall = async (call: () => Promise, eagerSession = false): Promise => { await waitForStageRelease(); if (stageFinalized) { throw parallelFailFastError(); @@ -2483,6 +2483,20 @@ export async function run>( } stageSnapshot.status = "running"; stageSnapshot.startedAt = Date.now(); + if (eagerSession && options?.model === undefined && options?.fallbackModels === undefined) { + try { + await innerCtx.__ensureSession(); + } catch (err) { + if (!(err instanceof Error && err.message.includes("prompt adapter not configured"))) { + throw err; + } + } + } + const startingModelMeta = innerCtx.__modelFallbackMeta(); + if (startingModelMeta.model !== undefined) stageSnapshot.model = startingModelMeta.model; + if (startingModelMeta.fastMode === true) stageSnapshot.fastMode = startingModelMeta.fastMode; + if (startingModelMeta.attemptedModels !== undefined) stageSnapshot.attemptedModels = startingModelMeta.attemptedModels; + if (startingModelMeta.modelAttempts !== undefined) stageSnapshot.modelAttempts = startingModelMeta.modelAttempts; activeStore.recordStageStart(runId, stageSnapshot); // Persistence: append stage.start entry @@ -2629,7 +2643,7 @@ export async function run>( const stageContext: StageContext & Pick = { name: innerCtx.name, - prompt: (text, promptOptions) => runTrackedStageCall(() => innerCtx.prompt(text, promptOptions)), + prompt: (text, promptOptions) => runTrackedStageCall(() => innerCtx.prompt(text, promptOptions), true), complete: (text, completeOptions) => runTrackedStageCall(() => innerCtx.complete(text, completeOptions)), steer: (text) => innerCtx.steer(text), followUp: (text) => innerCtx.followUp(text), diff --git a/packages/workflows/src/runs/foreground/stage-runner.ts b/packages/workflows/src/runs/foreground/stage-runner.ts index adb5a02b4..1f6f875cb 100644 --- a/packages/workflows/src/runs/foreground/stage-runner.ts +++ b/packages/workflows/src/runs/foreground/stage-runner.ts @@ -53,6 +53,8 @@ export interface StageSessionRuntime { readonly isStreaming: AgentSession["isStreaming"]; /** Number of SDK-level queued steering/follow-up messages, when supported. */ readonly pendingMessageCount?: number; + /** Settings manager supplied by the Atomic SDK when the adapter did not pre-create one. */ + readonly settingsManager?: WorkflowFastModeSettingsManager; navigateTree: AgentSession["navigateTree"]; compact: AgentSession["compact"]; abortCompaction(): void; @@ -570,7 +572,7 @@ export function createStageContext(opts: StageRunnerOpts): InternalStageContext function attachSession(created: StageSessionRuntime | StageSessionCreateResult): StageSessionRuntime { const result = normalizeSessionCreateResult(created); session = result.session; - sessionSettingsManager = result.settingsManager; + sessionSettingsManager = result.settingsManager ?? result.session.settingsManager; if (pendingThinkingLevel !== undefined) { result.session.setThinkingLevel(pendingThinkingLevel); } diff --git a/test/unit/stage-runner.test.ts b/test/unit/stage-runner.test.ts index 1edfa8bae..69bb32ca6 100644 --- a/test/unit/stage-runner.test.ts +++ b/test/unit/stage-runner.test.ts @@ -484,6 +484,28 @@ describe("createStageContext — model fallback", () => { assert.equal(ctx.__modelFallbackMeta().fastMode, true); }); + test("workflow fast mode metadata uses the session settings manager when the adapter result omits one", async () => { + const agentSession: AgentSessionAdapter = { + async create() { + const { session } = makeMockSession({ + model: { provider: "openai", id: "gpt-5.1-codex" } as AgentSession["model"], + settingsManager: { + getCodexFastModeSettings: () => ({ chat: false, workflow: true }), + }, + async prompt() {}, + }); + return session; + }, + }; + + const ctx = createStageContext(makeOpts({ adapters: { agentSession } })) as InternalStageContext; + + await ctx.prompt("go"); + + assert.equal(ctx.__modelFallbackMeta().model, "openai/gpt-5.1-codex"); + assert.equal(ctx.__modelFallbackMeta().fastMode, true); + }); + test("workflow fast mode metadata does not reload settings when no manager is provided", async () => { const agentSession: AgentSessionAdapter = { async create() { From c462b8e70ef44f2918f89d7cd0868639fdadcb06 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Sat, 30 May 2026 19:49:31 -0700 Subject: [PATCH 11/16] fix(workflows): show fast marker for explicit model stages --- packages/workflows/CHANGELOG.md | 2 +- .../workflows/src/runs/foreground/executor.ts | 10 +++- test/unit/executor.test.ts | 51 +++++++++++++++++++ 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/packages/workflows/CHANGELOG.md b/packages/workflows/CHANGELOG.md index b3b9118d4..43a81c3fa 100644 --- a/packages/workflows/CHANGELOG.md +++ b/packages/workflows/CHANGELOG.md @@ -9,7 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed - Kept workflow stage model metadata as the raw model id while surfacing Codex fast mode as a separate visible `fast` marker on workflow node cards, including stages that use the default Atomic SDK adapter settings manager. -- Show workflow Codex fast-mode metadata on running workflow nodes as soon as the stage session starts, including custom resource-loader stages whose settings manager is created by the Atomic SDK. +- Show workflow Codex fast-mode metadata on running workflow nodes as soon as the stage session starts, including explicit-model stages and custom resource-loader stages whose settings manager is created by the Atomic SDK. ## [0.8.21] - 2026-05-30 diff --git a/packages/workflows/src/runs/foreground/executor.ts b/packages/workflows/src/runs/foreground/executor.ts index 21cd89ec2..ac6dd843b 100644 --- a/packages/workflows/src/runs/foreground/executor.ts +++ b/packages/workflows/src/runs/foreground/executor.ts @@ -71,7 +71,7 @@ import { appendStageEnd, appendRunEnd, } from "../../shared/persistence-session-entries.js"; -import { validateWorkflowModels } from "../shared/model-fallback.js"; +import { validateWorkflowModels, workflowModelId } from "../shared/model-fallback.js"; import type { WorkflowFailure } from "../../shared/workflow-failures.js"; import { classifyWorkflowFailure } from "../../shared/workflow-failures.js"; import { selectPromptCallsiteFrame } from "../shared/prompt-callsite.js"; @@ -2483,7 +2483,13 @@ export async function run>( } stageSnapshot.status = "running"; stageSnapshot.startedAt = Date.now(); - if (eagerSession && options?.model === undefined && options?.fallbackModels === undefined) { + const hasExplicitFastModeCandidate = (candidate: StageOptions["model"]): boolean => { + const modelId = workflowModelId(candidate); + return modelId !== undefined && (modelId.startsWith("openai/") || modelId.startsWith("openai-codex/")); + }; + const explicitFastModeCandidate = hasExplicitFastModeCandidate(options?.model) + || (Array.isArray(options?.fallbackModels) && options.fallbackModels.some(hasExplicitFastModeCandidate)); + if (eagerSession && (options?.model === undefined && options?.fallbackModels === undefined || explicitFastModeCandidate)) { try { await innerCtx.__ensureSession(); } catch (err) { diff --git a/test/unit/executor.test.ts b/test/unit/executor.test.ts index a0d458bfe..0e6435825 100644 --- a/test/unit/executor.test.ts +++ b/test/unit/executor.test.ts @@ -1563,6 +1563,57 @@ describe("executor.run", () => { assert.deepEqual(result.stages[0]?.modelAttempts?.map((attempt) => attempt.success), [false, false]); }); + test("explicit model stage publishes running fast-mode metadata before prompt resolves", async () => { + const promptGate = deferred(); + const st = createStore(); + const def = defineWorkflow("explicit-model-running-fast-metadata") + .run(async (ctx) => { + await ctx.stage("scout", { model: "openai/gpt-5.1-codex" }).prompt("inspect"); + return { ok: true }; + }) + .compile(); + + const runPromise = run(def, {}, { + adapters: { + agentSession: { + async create() { + return { + session: { + ...mockSession(), + model: { provider: "openai", id: "gpt-5.1-codex" } as AgentSession["model"], + async prompt() { + await promptGate.promise; + }, + }, + settingsManager: { + getCodexFastModeSettings: () => ({ chat: false, workflow: true }), + }, + }; + }, + }, + }, + store: st, + }); + + try { + const deadline = Date.now() + 1000; + let runningStage: StageSnapshot | undefined; + while (Date.now() < deadline) { + runningStage = st.runs() + .flatMap((runSnapshot) => runSnapshot.stages) + .find((stage) => stage.name === "scout" && stage.status === "running"); + if (runningStage !== undefined) break; + await sleep(5); + } + + assert.equal(runningStage?.model, "openai/gpt-5.1-codex"); + assert.equal(runningStage?.fastMode, true); + } finally { + promptGate.resolve(); + await runPromise; + } + }); + test("invalid dynamic stage model fails before SDK session creation", async () => { let creates = 0; const def = defineWorkflow("invalid-stage-model") From a1a3382d7ec0f80be8d2707738ddb2f153b5c71a Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Sat, 30 May 2026 20:01:29 -0700 Subject: [PATCH 12/16] fix(workflows): resolve fast metadata model aliases --- packages/workflows/CHANGELOG.md | 2 +- .../workflows/src/runs/foreground/executor.ts | 26 ++++++--- test/unit/executor.test.ts | 57 +++++++++++++++++++ 3 files changed, 77 insertions(+), 8 deletions(-) diff --git a/packages/workflows/CHANGELOG.md b/packages/workflows/CHANGELOG.md index 43a81c3fa..8d48e1861 100644 --- a/packages/workflows/CHANGELOG.md +++ b/packages/workflows/CHANGELOG.md @@ -9,7 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed - Kept workflow stage model metadata as the raw model id while surfacing Codex fast mode as a separate visible `fast` marker on workflow node cards, including stages that use the default Atomic SDK adapter settings manager. -- Show workflow Codex fast-mode metadata on running workflow nodes as soon as the stage session starts, including explicit-model stages and custom resource-loader stages whose settings manager is created by the Atomic SDK. +- Show workflow Codex fast-mode metadata on running workflow nodes as soon as the stage session starts, including explicit-model stages, catalog-resolved bare model aliases, and custom resource-loader stages whose settings manager is created by the Atomic SDK. ## [0.8.21] - 2026-05-30 diff --git a/packages/workflows/src/runs/foreground/executor.ts b/packages/workflows/src/runs/foreground/executor.ts index ac6dd843b..734377658 100644 --- a/packages/workflows/src/runs/foreground/executor.ts +++ b/packages/workflows/src/runs/foreground/executor.ts @@ -71,7 +71,7 @@ import { appendStageEnd, appendRunEnd, } from "../../shared/persistence-session-entries.js"; -import { validateWorkflowModels, workflowModelId } from "../shared/model-fallback.js"; +import { buildModelCandidatesFromCatalog, validateWorkflowModels, workflowModelId } from "../shared/model-fallback.js"; import type { WorkflowFailure } from "../../shared/workflow-failures.js"; import { classifyWorkflowFailure } from "../../shared/workflow-failures.js"; import { selectPromptCallsiteFrame } from "../shared/prompt-callsite.js"; @@ -2483,13 +2483,25 @@ export async function run>( } stageSnapshot.status = "running"; stageSnapshot.startedAt = Date.now(); - const hasExplicitFastModeCandidate = (candidate: StageOptions["model"]): boolean => { - const modelId = workflowModelId(candidate); - return modelId !== undefined && (modelId.startsWith("openai/") || modelId.startsWith("openai-codex/")); + const isFastModeCandidateId = (modelId: string | undefined): boolean => + modelId !== undefined && (modelId.startsWith("openai/") || modelId.startsWith("openai-codex/")); + const hasExplicitFastModeCandidate = async (): Promise => { + const rawCandidate = isFastModeCandidateId(workflowModelId(options?.model)) + || (Array.isArray(options?.fallbackModels) && options.fallbackModels.some((candidate) => isFastModeCandidateId(workflowModelId(candidate)))); + if (rawCandidate) return true; + try { + const candidates = await buildModelCandidatesFromCatalog({ + primaryModel: options?.model, + fallbackModels: options?.fallbackModels, + catalog: opts.models, + }); + return candidates.some((candidate) => isFastModeCandidateId(candidate.id)); + } catch { + return false; + } }; - const explicitFastModeCandidate = hasExplicitFastModeCandidate(options?.model) - || (Array.isArray(options?.fallbackModels) && options.fallbackModels.some(hasExplicitFastModeCandidate)); - if (eagerSession && (options?.model === undefined && options?.fallbackModels === undefined || explicitFastModeCandidate)) { + const hasNoExplicitModelConfig = options?.model === undefined && options?.fallbackModels === undefined; + if (eagerSession && (hasNoExplicitModelConfig || await hasExplicitFastModeCandidate())) { try { await innerCtx.__ensureSession(); } catch (err) { diff --git a/test/unit/executor.test.ts b/test/unit/executor.test.ts index 0e6435825..5a21e0524 100644 --- a/test/unit/executor.test.ts +++ b/test/unit/executor.test.ts @@ -1614,6 +1614,63 @@ describe("executor.run", () => { } }); + test("bare explicit model stage publishes running fast-mode metadata after catalog resolution", async () => { + const promptGate = deferred(); + const st = createStore(); + const def = defineWorkflow("bare-explicit-model-running-fast-metadata") + .run(async (ctx) => { + await ctx.stage("scout", { model: "gpt-5.1-codex" }).prompt("inspect"); + return { ok: true }; + }) + .compile(); + + const runPromise = run(def, {}, { + models: { + listModels: async () => [ + { provider: "openai", id: "gpt-5.1-codex", fullId: "openai/gpt-5.1-codex" }, + ], + }, + adapters: { + agentSession: { + async create(options) { + assert.equal((options as { readonly model?: string }).model, "openai/gpt-5.1-codex"); + return { + session: { + ...mockSession(), + model: { provider: "openai", id: "gpt-5.1-codex" } as AgentSession["model"], + async prompt() { + await promptGate.promise; + }, + }, + settingsManager: { + getCodexFastModeSettings: () => ({ chat: false, workflow: true }), + }, + }; + }, + }, + }, + store: st, + }); + + try { + const deadline = Date.now() + 1000; + let runningStage: StageSnapshot | undefined; + while (Date.now() < deadline) { + runningStage = st.runs() + .flatMap((runSnapshot) => runSnapshot.stages) + .find((stage) => stage.name === "scout" && stage.status === "running"); + if (runningStage !== undefined) break; + await sleep(5); + } + + assert.equal(runningStage?.model, "openai/gpt-5.1-codex"); + assert.equal(runningStage?.fastMode, true); + } finally { + promptGate.resolve(); + await runPromise; + } + }); + test("invalid dynamic stage model fails before SDK session creation", async () => { let creates = 0; const def = defineWorkflow("invalid-stage-model") From b53f332667fc3033abd0f3dde74d0083f663722a Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Sat, 30 May 2026 20:10:22 -0700 Subject: [PATCH 13/16] fix(workflows): synchronize fast metadata across fallback --- .../components/fast-mode-selector.ts | 2 +- .../test/fast-mode-selector.test.ts | 1 + packages/workflows/CHANGELOG.md | 1 + .../workflows/src/runs/foreground/executor.ts | 37 +++--- .../src/runs/foreground/stage-runner.ts | 31 +++-- test/unit/executor.test.ts | 125 ++++++++++++++++++ 6 files changed, 170 insertions(+), 27 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/fast-mode-selector.ts b/packages/coding-agent/src/modes/interactive/components/fast-mode-selector.ts index 99a365d11..41d6c9505 100644 --- a/packages/coding-agent/src/modes/interactive/components/fast-mode-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/fast-mode-selector.ts @@ -38,7 +38,7 @@ export class FastModeSelectorComponent { lines.push(this.renderRow(row, width)); } lines.push(""); - lines.push(truncateToWidth(theme.fg("dim", "tab row · ←/→ change · esc close"), width)); + lines.push(truncateToWidth(theme.fg("dim", "tab/↑↓ row · ← enable · → disable · esc close"), width)); return lines.map((line) => truncateToWidth(line, width)); } diff --git a/packages/coding-agent/test/fast-mode-selector.test.ts b/packages/coding-agent/test/fast-mode-selector.test.ts index a49c43413..327805914 100644 --- a/packages/coding-agent/test/fast-mode-selector.test.ts +++ b/packages/coding-agent/test/fast-mode-selector.test.ts @@ -24,6 +24,7 @@ describe("FastModeSelectorComponent", () => { expect(rendered).toContain("workflow"); expect(rendered).toContain("[disabled]"); expect(rendered).toContain("[enabled]"); + expect(rendered).toContain("← enable · → disable"); }); it("moves rows with tab and shift-tab", () => { diff --git a/packages/workflows/CHANGELOG.md b/packages/workflows/CHANGELOG.md index 8d48e1861..39a5c9a4e 100644 --- a/packages/workflows/CHANGELOG.md +++ b/packages/workflows/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Kept workflow stage model metadata as the raw model id while surfacing Codex fast mode as a separate visible `fast` marker on workflow node cards, including stages that use the default Atomic SDK adapter settings manager. - Show workflow Codex fast-mode metadata on running workflow nodes as soon as the stage session starts, including explicit-model stages, catalog-resolved bare model aliases, and custom resource-loader stages whose settings manager is created by the Atomic SDK. +- Kept workflow Codex fast-mode markers synchronized across fallback attempts: running nodes now update when fallback switches to a fast-eligible model, completed nodes clear stale `fast` markers when fallback finishes on a non-eligible model, and prompt-adapter stages no longer create SDK sessions only to compute fast metadata. ## [0.8.21] - 2026-05-30 diff --git a/packages/workflows/src/runs/foreground/executor.ts b/packages/workflows/src/runs/foreground/executor.ts index 734377658..da10f8d87 100644 --- a/packages/workflows/src/runs/foreground/executor.ts +++ b/packages/workflows/src/runs/foreground/executor.ts @@ -2189,6 +2189,16 @@ export async function run>( // d. Create inner AgentSession-like StageContext (raw, without lifecycle wrapping). // Must come before the registry registration because the handle // delegates to it for every operation. + const applyModelFallbackMeta = (meta: ReturnType): void => { + if (meta.model !== undefined) stageSnapshot.model = meta.model; + if (meta.fastMode !== undefined) { + if (meta.fastMode) stageSnapshot.fastMode = true; + else delete stageSnapshot.fastMode; + } + if (meta.attemptedModels !== undefined) stageSnapshot.attemptedModels = meta.attemptedModels; + if (meta.modelAttempts !== undefined) stageSnapshot.modelAttempts = meta.modelAttempts; + }; + const innerCtx: InternalStageContext = createStageContext({ stageId, stageName: name, @@ -2197,6 +2207,12 @@ export async function run>( signal: ownController.signal, stageOptions: options, models: opts.models, + onModelFallbackMetaChange(meta) { + applyModelFallbackMeta(meta); + if (stageSnapshot.status === "running") { + activeStore.recordStageStart(runId, stageSnapshot); + } + }, }); const activeAskUserQuestionCalls = new Set(); let activeAskUserQuestionAnonymousCalls = 0; @@ -2353,11 +2369,7 @@ export async function run>( stageSnapshot.endedAt = Date.now(); stageSnapshot.durationMs = elapsedStageMs(stageSnapshot, stageSnapshot.endedAt); - const finalModelMeta = innerCtx.__modelFallbackMeta(); - if (finalModelMeta.model !== undefined) stageSnapshot.model = finalModelMeta.model; - if (finalModelMeta.fastMode === true) stageSnapshot.fastMode = finalModelMeta.fastMode; - if (finalModelMeta.attemptedModels !== undefined) stageSnapshot.attemptedModels = finalModelMeta.attemptedModels; - if (finalModelMeta.modelAttempts !== undefined) stageSnapshot.modelAttempts = finalModelMeta.modelAttempts; + applyModelFallbackMeta(innerCtx.__modelFallbackMeta()); activeStore.recordStageEnd(runId, stageSnapshot); opts.onStageEnd?.(runId, stageSnapshot); @@ -2501,7 +2513,8 @@ export async function run>( } }; const hasNoExplicitModelConfig = options?.model === undefined && options?.fallbackModels === undefined; - if (eagerSession && (hasNoExplicitModelConfig || await hasExplicitFastModeCandidate())) { + const promptAdapterHandlesInitialPrompt = adapters.prompt !== undefined; + if (eagerSession && !promptAdapterHandlesInitialPrompt && (hasNoExplicitModelConfig || await hasExplicitFastModeCandidate())) { try { await innerCtx.__ensureSession(); } catch (err) { @@ -2510,11 +2523,7 @@ export async function run>( } } } - const startingModelMeta = innerCtx.__modelFallbackMeta(); - if (startingModelMeta.model !== undefined) stageSnapshot.model = startingModelMeta.model; - if (startingModelMeta.fastMode === true) stageSnapshot.fastMode = startingModelMeta.fastMode; - if (startingModelMeta.attemptedModels !== undefined) stageSnapshot.attemptedModels = startingModelMeta.attemptedModels; - if (startingModelMeta.modelAttempts !== undefined) stageSnapshot.modelAttempts = startingModelMeta.modelAttempts; + applyModelFallbackMeta(innerCtx.__modelFallbackMeta()); activeStore.recordStageStart(runId, stageSnapshot); // Persistence: append stage.start entry @@ -2590,11 +2599,7 @@ export async function run>( if (meta.sessionId !== undefined || meta.sessionFile !== undefined) { activeStore.recordStageSession(runId, stageId, meta); } - const modelMeta = innerCtx.__modelFallbackMeta(); - if (modelMeta.model !== undefined) stageSnapshot.model = modelMeta.model; - if (modelMeta.fastMode === true) stageSnapshot.fastMode = modelMeta.fastMode; - if (modelMeta.attemptedModels !== undefined) stageSnapshot.attemptedModels = modelMeta.attemptedModels; - if (modelMeta.modelAttempts !== undefined) stageSnapshot.modelAttempts = modelMeta.modelAttempts; + applyModelFallbackMeta(innerCtx.__modelFallbackMeta()); } if (stageFailFastScope?.failed === true && stageFailFastScope.activeStages.has(stageId)) { markSkippedForParallelFailFast(); diff --git a/packages/workflows/src/runs/foreground/stage-runner.ts b/packages/workflows/src/runs/foreground/stage-runner.ts index 1f6f875cb..bda647d70 100644 --- a/packages/workflows/src/runs/foreground/stage-runner.ts +++ b/packages/workflows/src/runs/foreground/stage-runner.ts @@ -117,6 +117,8 @@ export interface StageRunnerOpts { signal?: AbortSignal; /** Optional model catalog used for fallback validation/resolution. */ models?: WorkflowModelCatalogPort; + /** Internal: notifies the executor when an in-flight fallback changes model/fast metadata. */ + onModelFallbackMetaChange?: (meta: StageModelFallbackMeta) => void; } export interface InternalStageContext extends StageContext { @@ -564,6 +566,23 @@ export function createStageContext(opts: StageRunnerOpts): InternalStageContext return shouldApplyCodexFastModeForScope(model, settingsManager.getCodexFastModeSettings(), "workflow"); } + function currentModelFallbackMeta(): StageModelFallbackMeta { + const attemptedModels = modelAttempts.map((attempt) => attempt.model); + const model = selectedModel ?? workflowModelId(session?.model); + const fastMode = isWorkflowFastModeEnabled(); + return { + ...(model !== undefined ? { model } : {}), + ...(fastMode !== undefined ? { fastMode } : {}), + ...(attemptedModels.length > 0 ? { attemptedModels } : {}), + ...(modelAttempts.length > 0 ? { modelAttempts: [...modelAttempts] } : {}), + ...(modelWarnings.length > 0 ? { warnings: [...modelWarnings] } : {}), + }; + } + + function notifyModelFallbackMetaChange(): void { + opts.onModelFallbackMetaChange?.(currentModelFallbackMeta()); + } + function normalizeSessionCreateResult(created: StageSessionRuntime | StageSessionCreateResult): StageSessionCreateResult { if ("session" in created) return created; return { session: created }; @@ -691,6 +710,7 @@ export function createStageContext(opts: StageRunnerOpts): InternalStageContext : await createSession(candidate, consumer); activeCandidateIndex = index; selectedModel = candidate.id; + notifyModelFallbackMetaChange(); try { await promptWithPauseResume(activeSession, text, sdkOptions); modelAttempts.push({ model: candidate.id, success: true }); @@ -875,16 +895,7 @@ export function createStageContext(opts: StageRunnerOpts): InternalStageContext }, __modelFallbackMeta() { - const attemptedModels = modelAttempts.map((attempt) => attempt.model); - const model = selectedModel ?? workflowModelId(session?.model); - const fastMode = isWorkflowFastModeEnabled(); - return { - ...(model !== undefined ? { model } : {}), - ...(fastMode === true ? { fastMode } : {}), - ...(attemptedModels.length > 0 ? { attemptedModels } : {}), - ...(modelAttempts.length > 0 ? { modelAttempts: [...modelAttempts] } : {}), - ...(modelWarnings.length > 0 ? { warnings: [...modelWarnings] } : {}), - }; + return currentModelFallbackMeta(); }, async __requestPause() { diff --git a/test/unit/executor.test.ts b/test/unit/executor.test.ts index 5a21e0524..2c665cb88 100644 --- a/test/unit/executor.test.ts +++ b/test/unit/executor.test.ts @@ -1671,6 +1671,131 @@ describe("executor.run", () => { } }); + test("prompt adapter stages do not eagerly create SDK sessions for fast metadata", async () => { + const st = createStore(); + const def = defineWorkflow("prompt-adapter-no-eager-session") + .run(async (ctx) => { + const text = await ctx.stage("scout").prompt("inspect"); + return { text }; + }) + .compile(); + + const result = await run(def, {}, { + adapters: { + prompt: { + prompt: async () => "adapter ok", + }, + agentSession: { + async create() { + throw new Error("agent session should not be created"); + }, + }, + }, + store: st, + }); + + assert.equal(result.status, "completed"); + assert.equal(result.stages[0]?.result, "adapter ok"); + }); + + test("workflow fallback refreshes running fast metadata when switching to an eligible model", async () => { + const fallbackGate = deferred(); + const st = createStore(); + const def = defineWorkflow("fallback-running-fast-metadata") + .run(async (ctx) => { + await ctx.stage("scout", { model: "anthropic/primary", fallbackModels: ["openai/fallback"] }).prompt("inspect"); + return { ok: true }; + }) + .compile(); + + const runPromise = run(def, {}, { + adapters: { + agentSession: { + async create(options) { + const model = (options as { readonly model?: string }).model; + return { + session: { + ...mockSession(), + model: model === "openai/fallback" + ? { provider: "openai", id: "fallback" } as AgentSession["model"] + : { provider: "anthropic", id: "primary" } as AgentSession["model"], + async prompt() { + if (model === "openai/fallback") { + await fallbackGate.promise; + return; + } + throw new Error("anthropic/primary timed out"); + }, + }, + settingsManager: { + getCodexFastModeSettings: () => ({ chat: false, workflow: true }), + }, + }; + }, + }, + }, + store: st, + }); + + try { + const deadline = Date.now() + 1000; + let runningStage: StageSnapshot | undefined; + while (Date.now() < deadline) { + runningStage = st.runs() + .flatMap((runSnapshot) => runSnapshot.stages) + .find((stage) => stage.name === "scout" && stage.status === "running" && stage.model === "openai/fallback"); + if (runningStage?.fastMode === true) break; + await sleep(5); + } + + assert.equal(runningStage?.model, "openai/fallback"); + assert.equal(runningStage?.fastMode, true); + } finally { + fallbackGate.resolve(); + await runPromise; + } + }); + + test("workflow fallback clears fast metadata when final model is not eligible", async () => { + const st = createStore(); + const def = defineWorkflow("fallback-clears-fast-metadata") + .run(async (ctx) => { + await ctx.stage("scout", { model: "openai/gpt-5.1-codex", fallbackModels: ["anthropic/claude-sonnet-4"] }).prompt("inspect"); + return { ok: true }; + }) + .compile(); + + const result = await run(def, {}, { + adapters: { + agentSession: { + async create(options) { + const model = (options as { readonly model?: string }).model; + return { + session: { + ...mockSession(), + model: model === "anthropic/claude-sonnet-4" + ? { provider: "anthropic", id: "claude-sonnet-4" } as AgentSession["model"] + : { provider: "openai", id: "gpt-5.1-codex" } as AgentSession["model"], + async prompt() { + if (model === "anthropic/claude-sonnet-4") return; + throw new Error("openai/gpt-5.1-codex timed out"); + }, + }, + settingsManager: { + getCodexFastModeSettings: () => ({ chat: false, workflow: true }), + }, + }; + }, + }, + }, + store: st, + }); + + assert.equal(result.status, "completed"); + assert.equal(result.stages[0]?.model, "anthropic/claude-sonnet-4"); + assert.equal(result.stages[0]?.fastMode, undefined); + }); + test("invalid dynamic stage model fails before SDK session creation", async () => { let creates = 0; const def = defineWorkflow("invalid-stage-model") From 72d54c5e0a10abe7ae72bc1d3e715feb5abfb5e0 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Sat, 30 May 2026 20:18:34 -0700 Subject: [PATCH 14/16] fix: preserve fast mode scope settings --- packages/coding-agent/CHANGELOG.md | 2 +- .../coding-agent/src/core/codex-fast-mode.ts | 5 ++++ .../coding-agent/src/core/settings-manager.ts | 30 +++++++++++++------ packages/coding-agent/src/index.ts | 1 + .../components/fast-mode-selector.ts | 6 ++-- .../src/modes/interactive/interactive-mode.ts | 4 +-- .../coding-agent/test/codex-fast-mode.test.ts | 9 ++++++ .../test/fast-mode-selector.test.ts | 6 ++-- .../settings-manager-codex-fast-mode.test.ts | 22 ++++++++++++++ .../workflows/src/runs/foreground/executor.ts | 10 +++---- 10 files changed, 71 insertions(+), 24 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 48403896a..1666cdf3a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,7 +8,7 @@ ### Fixed -- Fixed `/fast` persistence so existing project-level fast-mode overrides are updated alongside global settings instead of masking the newly selected chat or workflow fast-mode state ([#1134](https://github.com/flora131/atomic/issues/1134)). +- Fixed `/fast` persistence so existing project-level fast-mode overrides are updated alongside global settings for the changed scope without clobbering untouched global chat or workflow fast-mode preferences ([#1134](https://github.com/flora131/atomic/issues/1134)). - Made Codex fast-mode request helpers require an explicit enabled flag and treat `service_tier: undefined` as unset when preparing OpenAI payloads ([#1134](https://github.com/flora131/atomic/issues/1134)). - Fixed attached workflow-stage chat footers to resolve the `fast` model indicator against workflow fast-mode settings instead of chat settings ([#1134](https://github.com/flora131/atomic/issues/1134)). diff --git a/packages/coding-agent/src/core/codex-fast-mode.ts b/packages/coding-agent/src/core/codex-fast-mode.ts index b9dc9a82c..b2c37c944 100644 --- a/packages/coding-agent/src/core/codex-fast-mode.ts +++ b/packages/coding-agent/src/core/codex-fast-mode.ts @@ -18,6 +18,11 @@ export function isCodexFastModeSupportedProvider(provider: string): boolean { return provider === "openai" || provider === "openai-codex"; } +export function isCodexFastModeCandidateModelId(modelId: string | undefined): boolean { + const provider = modelId?.split("/", 1)[0]; + return provider !== undefined && isCodexFastModeSupportedProvider(provider); +} + export function isCodexFastModeSupportedModel(model: Pick, "provider">): boolean { return isCodexFastModeSupportedProvider(model.provider); } diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 5bedabfa6..98f0e1166 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -1127,29 +1127,41 @@ export class SettingsManager { }; } - setCodexFastModeSettings(settings: { chat: boolean; workflow: boolean }): void { + setCodexFastModeSettings(settings: Partial<{ chat: boolean; workflow: boolean }>): void { + if (settings.chat === undefined && settings.workflow === undefined) { + return; + } if (!this.globalSettings.codexFastMode) { this.globalSettings.codexFastMode = {}; } - this.globalSettings.codexFastMode.chat = settings.chat; - this.globalSettings.codexFastMode.workflow = settings.workflow; - this.markModified("codexFastMode", "chat"); - this.markModified("codexFastMode", "workflow"); + if (settings.chat !== undefined) { + this.globalSettings.codexFastMode.chat = settings.chat; + this.markModified("codexFastMode", "chat"); + } + if (settings.workflow !== undefined) { + this.globalSettings.codexFastMode.workflow = settings.workflow; + this.markModified("codexFastMode", "workflow"); + } const projectCodexFastMode = this.projectSettings.codexFastMode; const projectOverridesChat = projectCodexFastMode?.chat !== undefined; const projectOverridesWorkflow = projectCodexFastMode?.workflow !== undefined; - if (projectOverridesChat || projectOverridesWorkflow) { + let projectModified = false; + if ((settings.chat !== undefined && projectOverridesChat) || (settings.workflow !== undefined && projectOverridesWorkflow)) { this.projectSettings.codexFastMode = { ...(projectCodexFastMode ?? {}) }; - if (projectOverridesChat) { + if (settings.chat !== undefined && projectOverridesChat) { this.projectSettings.codexFastMode.chat = settings.chat; this.markProjectModified("codexFastMode", "chat"); + projectModified = true; } - if (projectOverridesWorkflow) { + if (settings.workflow !== undefined && projectOverridesWorkflow) { this.projectSettings.codexFastMode.workflow = settings.workflow; this.markProjectModified("codexFastMode", "workflow"); + projectModified = true; + } + if (projectModified) { + this.saveProjectSettings(this.projectSettings); } - this.saveProjectSettings(this.projectSettings); } this.save(); diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 4cddc863e..2759d4d34 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -77,6 +77,7 @@ export { getCodexFastModeScope, hasSupportedCodexFastModeModel, isCodexFastModeEnabledForScope, + isCodexFastModeCandidateModelId, isCodexFastModeSupportedModel, isCodexFastModeSupportedProvider, shouldApplyCodexFastMode, diff --git a/packages/coding-agent/src/modes/interactive/components/fast-mode-selector.ts b/packages/coding-agent/src/modes/interactive/components/fast-mode-selector.ts index 41d6c9505..7a3dbbe67 100644 --- a/packages/coding-agent/src/modes/interactive/components/fast-mode-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/fast-mode-selector.ts @@ -7,11 +7,11 @@ export interface FastModeSelectorConfig { } export interface FastModeSelectorCallbacks { - onChange: (settings: FastModeSelectorConfig) => void; + onChange: (settings: FastModeSelectorConfig, changedRow: FastModeRow) => void; onCancel: () => void | Promise; } -type FastModeRow = keyof FastModeSelectorConfig; +export type FastModeRow = keyof FastModeSelectorConfig; const ROWS: readonly FastModeRow[] = ["chat", "workflow"]; const DESCRIPTION = "Uses OpenAI priority service tier for supported openai/* and openai-codex/* models."; @@ -82,7 +82,7 @@ export class FastModeSelectorComponent { return; } this.state = { ...this.state, [row]: enabled }; - this.callbacks.onChange({ ...this.state }); + this.callbacks.onChange({ ...this.state }, row); } private renderRow(row: FastModeRow, width: number): string { diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 5fe6da3a2..ff160c668 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -4472,8 +4472,8 @@ export class InteractiveMode { const selector = new FastModeSelectorComponent( this.settingsManager.getCodexFastModeSettings(), { - onChange: (settings) => { - this.settingsManager.setCodexFastModeSettings(settings); + onChange: (settings, changedRow) => { + this.settingsManager.setCodexFastModeSettings({ [changedRow]: settings[changedRow] }); this.showStatus( `Codex fast mode: chat ${settings.chat ? "enabled" : "disabled"}, workflow ${settings.workflow ? "enabled" : "disabled"}`, ); diff --git a/packages/coding-agent/test/codex-fast-mode.test.ts b/packages/coding-agent/test/codex-fast-mode.test.ts index b9af06b96..18f7abbe7 100644 --- a/packages/coding-agent/test/codex-fast-mode.test.ts +++ b/packages/coding-agent/test/codex-fast-mode.test.ts @@ -6,6 +6,7 @@ import { hasSupportedCodexFastModeModel, isCodexFastModeEnabledForScope, isCodexFastModeEnabledForSession, + isCodexFastModeCandidateModelId, isCodexFastModeSupportedProvider, shouldApplyCodexFastModeForScope, withCodexFastModePayload, @@ -42,6 +43,14 @@ describe("codex fast mode helpers", () => { expect(hasSupportedCodexFastModeModel([model("openai-codex")])).toBe(true); }); + it("detects candidate model ids with the shared provider policy", () => { + expect(isCodexFastModeCandidateModelId("openai/gpt-5.1-codex")).toBe(true); + expect(isCodexFastModeCandidateModelId("openai-codex/gpt-5.1-codex")).toBe(true); + expect(isCodexFastModeCandidateModelId("anthropic/claude-sonnet-4")).toBe(false); + expect(isCodexFastModeCandidateModelId("gpt-5.1-codex")).toBe(false); + expect(isCodexFastModeCandidateModelId(undefined)).toBe(false); + }); + it("selects chat versus workflow scope from orchestration context", () => { expect(getCodexFastModeScope(undefined)).toBe("chat"); expect(getCodexFastModeScope(workflowContext)).toBe("workflow"); diff --git a/packages/coding-agent/test/fast-mode-selector.test.ts b/packages/coding-agent/test/fast-mode-selector.test.ts index 327805914..b51a47bb4 100644 --- a/packages/coding-agent/test/fast-mode-selector.test.ts +++ b/packages/coding-agent/test/fast-mode-selector.test.ts @@ -51,16 +51,16 @@ describe("FastModeSelectorComponent", () => { selector.handleInput("\x1b[D"); expect(selector.getSettings()).toEqual({ chat: true, workflow: false }); - expect(onChange).toHaveBeenLastCalledWith({ chat: true, workflow: false }); + expect(onChange).toHaveBeenLastCalledWith({ chat: true, workflow: false }, "chat"); selector.handleInput("\t"); selector.handleInput("\x1b[D"); expect(selector.getSettings()).toEqual({ chat: true, workflow: true }); - expect(onChange).toHaveBeenLastCalledWith({ chat: true, workflow: true }); + expect(onChange).toHaveBeenLastCalledWith({ chat: true, workflow: true }, "workflow"); selector.handleInput("\x1b[C"); expect(selector.getSettings()).toEqual({ chat: true, workflow: false }); - expect(onChange).toHaveBeenLastCalledWith({ chat: true, workflow: false }); + expect(onChange).toHaveBeenLastCalledWith({ chat: true, workflow: false }, "workflow"); }); it("cancels on escape", () => { diff --git a/packages/coding-agent/test/settings-manager-codex-fast-mode.test.ts b/packages/coding-agent/test/settings-manager-codex-fast-mode.test.ts index 7dd841245..c66709c40 100644 --- a/packages/coding-agent/test/settings-manager-codex-fast-mode.test.ts +++ b/packages/coding-agent/test/settings-manager-codex-fast-mode.test.ts @@ -77,4 +77,26 @@ describe("SettingsManager codexFastMode", () => { expect(savedGlobal.codexFastMode).toEqual({ chat: false, workflow: true }); expect(savedProject.codexFastMode).toEqual({ workflow: true }); }); + + it("does not clobber untouched global fast mode fields with project overrides", async () => { + writeFileSync( + join(agentDir, "settings.json"), + JSON.stringify({ codexFastMode: { chat: false, workflow: true } }, null, 2), + ); + mkdirSync(join(cwd, ".atomic"), { recursive: true }); + writeFileSync( + join(cwd, ".atomic", "settings.json"), + JSON.stringify({ codexFastMode: { workflow: false } }, null, 2), + ); + const manager = SettingsManager.create(cwd, agentDir); + + manager.setCodexFastModeSettings({ chat: true }); + await manager.flush(); + + expect(manager.getCodexFastModeSettings()).toEqual({ chat: true, workflow: false }); + const savedGlobal = JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf-8")); + const savedProject = JSON.parse(readFileSync(join(cwd, ".atomic", "settings.json"), "utf-8")); + expect(savedGlobal.codexFastMode).toEqual({ chat: true, workflow: true }); + expect(savedProject.codexFastMode).toEqual({ workflow: false }); + }); }); diff --git a/packages/workflows/src/runs/foreground/executor.ts b/packages/workflows/src/runs/foreground/executor.ts index da10f8d87..d90b4ace9 100644 --- a/packages/workflows/src/runs/foreground/executor.ts +++ b/packages/workflows/src/runs/foreground/executor.ts @@ -5,7 +5,7 @@ import { createHash } from "node:crypto"; import { mkdir, writeFile } from "node:fs/promises"; import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path"; -import { CONFIG_DIR_NAME, createAskUserQuestionToolDefinition } from "@bastani/atomic"; +import { CONFIG_DIR_NAME, createAskUserQuestionToolDefinition, isCodexFastModeCandidateModelId } from "@bastani/atomic"; import { stageUiBroker } from "../../shared/stage-ui-broker.js"; import { buildStagePromptAdapter } from "../../shared/stage-prompt.js"; import type { @@ -2495,11 +2495,9 @@ export async function run>( } stageSnapshot.status = "running"; stageSnapshot.startedAt = Date.now(); - const isFastModeCandidateId = (modelId: string | undefined): boolean => - modelId !== undefined && (modelId.startsWith("openai/") || modelId.startsWith("openai-codex/")); const hasExplicitFastModeCandidate = async (): Promise => { - const rawCandidate = isFastModeCandidateId(workflowModelId(options?.model)) - || (Array.isArray(options?.fallbackModels) && options.fallbackModels.some((candidate) => isFastModeCandidateId(workflowModelId(candidate)))); + const rawCandidate = isCodexFastModeCandidateModelId(workflowModelId(options?.model)) + || (Array.isArray(options?.fallbackModels) && options.fallbackModels.some((candidate) => isCodexFastModeCandidateModelId(workflowModelId(candidate)))); if (rawCandidate) return true; try { const candidates = await buildModelCandidatesFromCatalog({ @@ -2507,7 +2505,7 @@ export async function run>( fallbackModels: options?.fallbackModels, catalog: opts.models, }); - return candidates.some((candidate) => isFastModeCandidateId(candidate.id)); + return candidates.some((candidate) => isCodexFastModeCandidateModelId(candidate.id)); } catch { return false; } From 0d4bb0dc7aefdb55696e8a7b4431da673ca4a574 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Sun, 31 May 2026 00:40:40 -0700 Subject: [PATCH 15/16] fix(codex-fast-mode): propagate runtime fast mode state Assistant-model: GPT-5.5 --- packages/coding-agent/CHANGELOG.md | 6 + packages/coding-agent/src/config.ts | 50 ++++++ .../coding-agent/src/core/codex-fast-mode.ts | 132 +++++++++++++++- packages/coding-agent/src/core/sdk.ts | 4 +- .../coding-agent/src/core/settings-manager.ts | 32 +++- packages/coding-agent/src/index.ts | 1 + .../components/fast-mode-selector.ts | 61 +++++--- .../modes/interactive/components/footer.ts | 6 +- .../src/modes/interactive/interactive-mode.ts | 40 ++++- .../coding-agent/test/codex-fast-mode.test.ts | 144 +++++++++++++++++- .../test/fast-mode-selector.test.ts | 36 +++-- .../test/footer-codex-fast-mode.test.ts | 31 +++- .../interactive-mode-startup-banner.test.ts | 115 ++++++++++++++ .../test/sdk-codex-fast-mode.test.ts | 75 ++++++++- .../settings-manager-codex-fast-mode.test.ts | 41 +++++ 15 files changed, 715 insertions(+), 59 deletions(-) create mode 100644 packages/coding-agent/test/interactive-mode-startup-banner.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1666cdf3a..ff1ef6792 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,8 +6,14 @@ - Added `/fast` Codex fast mode toggles for chat and workflow-stage sessions, applying OpenAI priority service tier to supported `openai/*` and `openai-codex/*` models only; active supported models now show a visible `fast` indicator after the model name ([#1134](https://github.com/flora131/atomic/issues/1134)). +### Changed + +- Refined the `/fast` selector into a conventional toggle UI with on/off states, clearer scope descriptions, and space/enter toggle support ([#1134](https://github.com/flora131/atomic/issues/1134)). +- Compressed the `/fast` selector copy, row layout, and per-change status message so the summary, toggles, scopes, and keyboard hints stay readable without duplicate off/standard-tier messaging ([#1134](https://github.com/flora131/atomic/issues/1134)). + ### Fixed +- Fixed `/fast` changes so the banner/footer and current session update immediately, and inherited chat fast-mode state now reaches subagent child sessions without waiting for a restart ([#1134](https://github.com/flora131/atomic/issues/1134)). - Fixed `/fast` persistence so existing project-level fast-mode overrides are updated alongside global settings for the changed scope without clobbering untouched global chat or workflow fast-mode preferences ([#1134](https://github.com/flora131/atomic/issues/1134)). - Made Codex fast-mode request helpers require an explicit enabled flag and treat `service_tier: undefined` as unset when preparing OpenAI payloads ([#1134](https://github.com/flora131/atomic/issues/1134)). - Fixed attached workflow-stage chat footers to resolve the `fast` model indicator against workflow fast-mode settings instead of chat settings ([#1134](https://github.com/flora131/atomic/issues/1134)). diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index 02f3539fb..ab4365f06 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -487,8 +487,58 @@ export const ENV_SHARE_VIEWER_URL = `${ENV_PREFIX}_SHARE_VIEWER_URL`; export const ENV_CLEAR_ON_SHRINK = `${ENV_PREFIX}_CLEAR_ON_SHRINK`; export const ENV_HARDWARE_CURSOR = `${ENV_PREFIX}_HARDWARE_CURSOR`; export const ENV_TIMING = `${ENV_PREFIX}_TIMING`; +export const ENV_CODEX_FAST_MODE = `${ENV_PREFIX}_CODEX_FAST_MODE`; export const WORKFLOW_STAGE_SUBAGENT_GUARD_ENV = `${ENV_PREFIX}_WORKFLOW_STAGE_SUBAGENT_GUARD`; +export interface CodexFastModeEnvironmentSettings { + chat?: boolean; + workflow?: boolean; +} + +function parseCodexFastModeEnvBoolean(value: string | undefined): boolean | undefined { + switch (value?.trim().toLowerCase()) { + case "1": + case "true": + case "enabled": + case "on": + return true; + case "0": + case "false": + case "disabled": + case "off": + return false; + default: + return undefined; + } +} + +export function serializeCodexFastModeEnvironmentSettings(settings: Required): string { + return `chat=${settings.chat ? "1" : "0"};workflow=${settings.workflow ? "1" : "0"}`; +} + +export function parseCodexFastModeEnvironmentSettings(value: string | undefined): CodexFastModeEnvironmentSettings | undefined { + if (!value) return undefined; + const settings: CodexFastModeEnvironmentSettings = {}; + for (const part of value.split(/[;,]/)) { + const separatorIndex = part.indexOf("="); + if (separatorIndex === -1) continue; + const key = part.slice(0, separatorIndex).trim(); + const parsedValue = parseCodexFastModeEnvBoolean(part.slice(separatorIndex + 1)); + if (parsedValue === undefined) continue; + if (key === "chat") settings.chat = parsedValue; + if (key === "workflow") settings.workflow = parsedValue; + } + return settings.chat !== undefined || settings.workflow !== undefined ? settings : undefined; +} + +export function getCodexFastModeEnvironmentSettings(): CodexFastModeEnvironmentSettings | undefined { + return parseCodexFastModeEnvironmentSettings(getEnvValue(ENV_CODEX_FAST_MODE)); +} + +export function setCodexFastModeEnvironmentSettings(settings: Required): void { + setEnvValue(ENV_CODEX_FAST_MODE, serializeCodexFastModeEnvironmentSettings(settings)); +} + export function getEnvNames(name: string): string[] { if (ENV_PREFIX === LEGACY_ENV_PREFIX || !name.startsWith(`${ENV_PREFIX}_`)) return [name]; return [name, `${LEGACY_ENV_PREFIX}_${name.slice(ENV_PREFIX.length + 1)}`]; diff --git a/packages/coding-agent/src/core/codex-fast-mode.ts b/packages/coding-agent/src/core/codex-fast-mode.ts index b2c37c944..bb435ff75 100644 --- a/packages/coding-agent/src/core/codex-fast-mode.ts +++ b/packages/coding-agent/src/core/codex-fast-mode.ts @@ -1,4 +1,18 @@ -import type { Api, Model, SimpleStreamOptions } from "@earendil-works/pi-ai"; +import { + clampThinkingLevel, + type Api, + type AssistantMessageEventStream, + type Context, + type Model, + type OpenAICodexResponsesOptions, + streamOpenAICodexResponses, + streamOpenAIResponses, + streamSimple, + type OpenAIResponsesOptions, + type SimpleStreamOptions, + type StreamOptions, + type ThinkingLevel, +} from "@earendil-works/pi-ai"; import type { OrchestrationContext } from "./extensions/index.ts"; export const CODEX_FAST_MODE_SERVICE_TIER = "priority" as const; @@ -14,6 +28,30 @@ export interface CodexFastModeStreamOptions extends SimpleStreamOptions { serviceTier?: typeof CODEX_FAST_MODE_SERVICE_TIER; } +export interface CodexFastModeStreamers { + streamSimple: ( + model: Model, + context: Context, + options?: SimpleStreamOptions, + ) => AssistantMessageEventStream; + streamOpenAIResponses: ( + model: Model<"openai-responses">, + context: Context, + options?: OpenAIResponsesOptions, + ) => AssistantMessageEventStream; + streamOpenAICodexResponses: ( + model: Model<"openai-codex-responses">, + context: Context, + options?: OpenAICodexResponsesOptions, + ) => AssistantMessageEventStream; +} + +const DEFAULT_CODEX_FAST_MODE_STREAMERS: CodexFastModeStreamers = { + streamSimple, + streamOpenAIResponses, + streamOpenAICodexResponses, +}; + export function isCodexFastModeSupportedProvider(provider: string): boolean { return provider === "openai" || provider === "openai-codex"; } @@ -83,6 +121,98 @@ export function withCodexFastModeStreamOptions( }; } +export function isCodexFastModeNativeApi(api: Api): api is "openai-responses" | "openai-codex-responses" { + return api === "openai-responses" || api === "openai-codex-responses"; +} + +export function shouldUseNativeCodexFastMode( + model: Pick, "api" | "provider">, + options: CodexFastModeStreamOptions | undefined, +): boolean { + return ( + isCodexFastModeSupportedModel(model) && + isCodexFastModeNativeApi(model.api) && + options?.serviceTier === CODEX_FAST_MODE_SERVICE_TIER + ); +} + +function buildCodexFastModeBaseProviderOptions( + options: CodexFastModeStreamOptions | undefined, +): StreamOptions { + return { + temperature: options?.temperature, + maxTokens: options?.maxTokens, + signal: options?.signal, + apiKey: options?.apiKey, + transport: options?.transport, + cacheRetention: options?.cacheRetention, + sessionId: options?.sessionId, + onPayload: options?.onPayload, + onResponse: options?.onResponse, + headers: options?.headers, + timeoutMs: options?.timeoutMs, + websocketConnectTimeoutMs: options?.websocketConnectTimeoutMs, + maxRetries: options?.maxRetries, + maxRetryDelayMs: options?.maxRetryDelayMs, + metadata: options?.metadata, + }; +} + +export function mapCodexFastModeReasoningEffort( + model: Model, + reasoning: ThinkingLevel | undefined, +): ThinkingLevel | undefined { + const clampedReasoning = reasoning ? clampThinkingLevel(model, reasoning) : undefined; + return clampedReasoning === "off" ? undefined : clampedReasoning; +} + +export function buildOpenAIResponsesCodexFastModeOptions( + model: Model, + options: CodexFastModeStreamOptions | undefined, +): OpenAIResponsesOptions { + return { + ...buildCodexFastModeBaseProviderOptions(options), + reasoningEffort: mapCodexFastModeReasoningEffort(model, options?.reasoning), + serviceTier: options?.serviceTier, + }; +} + +export function buildOpenAICodexResponsesCodexFastModeOptions( + model: Model, + options: CodexFastModeStreamOptions | undefined, +): OpenAICodexResponsesOptions { + return { + ...buildCodexFastModeBaseProviderOptions(options), + reasoningEffort: mapCodexFastModeReasoningEffort(model, options?.reasoning), + serviceTier: options?.serviceTier, + }; +} + +export function streamWithCodexFastMode( + model: Model, + context: Context, + options: CodexFastModeStreamOptions | undefined, + streamers: CodexFastModeStreamers = DEFAULT_CODEX_FAST_MODE_STREAMERS, +): AssistantMessageEventStream { + if (shouldUseNativeCodexFastMode(model, options)) { + if (model.api === "openai-responses") { + return streamers.streamOpenAIResponses( + model as Model<"openai-responses">, + context, + buildOpenAIResponsesCodexFastModeOptions(model, options), + ); + } + + return streamers.streamOpenAICodexResponses( + model as Model<"openai-codex-responses">, + context, + buildOpenAICodexResponsesCodexFastModeOptions(model, options), + ); + } + + return streamers.streamSimple(model, context, options); +} + function isObjectPayload(payload: unknown): payload is Record { return typeof payload === "object" && payload !== null && !Array.isArray(payload); } diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index e29e44f4e..883f996da 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -9,7 +9,6 @@ import { type Api, type Message, type Model, - streamSimple, } from "@earendil-works/pi-ai"; import { APP_NAME, getAgentDir } from "../config.ts"; import { resolvePath } from "../utils/paths.ts"; @@ -18,6 +17,7 @@ import { formatNoModelsAvailableMessage } from "./auth-guidance.ts"; import { AuthStorage } from "./auth-storage.ts"; import { shouldApplyCodexFastMode, + streamWithCodexFastMode, withCodexFastModePayload, withCodexFastModeStreamOptions, } from "./codex-fast-mode.ts"; @@ -416,7 +416,7 @@ export async function createAgentSession( const providerRetrySettings = settingsManager.getProviderRetrySettings(); const attributionHeaders = getAttributionHeaders(model, settingsManager, streamOptions?.sessionId); const fastModeEnabled = isCodexFastModeEnabled(model); - return streamSimple( + return streamWithCodexFastMode( model, context, withCodexFastModeStreamOptions( diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 98f0e1166..02c8aa386 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -6,6 +6,7 @@ import { CONFIG_DIR_NAME, ENV_CLEAR_ON_SHRINK, ENV_HARDWARE_CURSOR, + getCodexFastModeEnvironmentSettings, getAgentConfigPaths, getAgentDir, getEnvValue, @@ -282,6 +283,7 @@ export class SettingsManager { private globalSettings: Settings; private projectSettings: Settings; private settings: Settings; + private runtimeSettingsOverrides: Settings; private modifiedFields = new Set(); // Track global fields modified during session private modifiedNestedFields = new Map>(); // Track global nested field modifications private modifiedProjectFields = new Set(); // Track project fields modified during session @@ -305,7 +307,20 @@ export class SettingsManager { this.globalSettingsLoadError = globalLoadError; this.projectSettingsLoadError = projectLoadError; this.errors = [...initialErrors]; - this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); + this.runtimeSettingsOverrides = SettingsManager.getRuntimeSettingsOverrides(); + this.settings = this.mergeEffectiveSettings(); + } + + private static getRuntimeSettingsOverrides(): Settings { + const codexFastMode = getCodexFastModeEnvironmentSettings(); + return codexFastMode ? { codexFastMode } : {}; + } + + private mergeEffectiveSettings(): Settings { + return deepMergeSettings( + deepMergeSettings(this.globalSettings, this.projectSettings), + this.runtimeSettingsOverrides, + ); } /** Create a SettingsManager that loads from files */ @@ -467,7 +482,8 @@ export class SettingsManager { this.recordError("project", projectLoad.error); } - this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); + this.runtimeSettingsOverrides = SettingsManager.getRuntimeSettingsOverrides(); + this.settings = this.mergeEffectiveSettings(); } /** Apply additional overrides on top of current settings */ @@ -564,7 +580,7 @@ export class SettingsManager { } private save(): void { - this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); + this.settings = this.mergeEffectiveSettings(); if (this.globalSettingsLoadError) { return; @@ -581,7 +597,7 @@ export class SettingsManager { private saveProjectSettings(settings: Settings): void { this.projectSettings = structuredClone(settings); - this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); + this.settings = this.mergeEffectiveSettings(); if (this.projectSettingsLoadError) { return; @@ -1164,6 +1180,14 @@ export class SettingsManager { } } + if (this.runtimeSettingsOverrides.codexFastMode) { + this.runtimeSettingsOverrides.codexFastMode = { + ...this.runtimeSettingsOverrides.codexFastMode, + ...(settings.chat !== undefined ? { chat: settings.chat } : {}), + ...(settings.workflow !== undefined ? { workflow: settings.workflow } : {}), + }; + } + this.save(); } } diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 2759d4d34..118799907 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -16,6 +16,7 @@ export { getProjectConfigPaths, getEnvNames, getEnvValue, + ENV_CODEX_FAST_MODE, WORKFLOW_STAGE_SUBAGENT_GUARD_ENV, isBunBinary, getUserConfigDirs, diff --git a/packages/coding-agent/src/modes/interactive/components/fast-mode-selector.ts b/packages/coding-agent/src/modes/interactive/components/fast-mode-selector.ts index 7a3dbbe67..62e9ba331 100644 --- a/packages/coding-agent/src/modes/interactive/components/fast-mode-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/fast-mode-selector.ts @@ -14,7 +14,18 @@ export interface FastModeSelectorCallbacks { export type FastModeRow = keyof FastModeSelectorConfig; const ROWS: readonly FastModeRow[] = ["chat", "workflow"]; -const DESCRIPTION = "Uses OpenAI priority service tier for supported openai/* and openai-codex/* models."; +const LABEL_WIDTH = 16; +const DESCRIPTION = "Priority tier for supported openai/* and openai-codex/* models."; +const ROW_DETAILS: Record = { + chat: { + label: "Chat", + scope: "this chat + subagents", + }, + workflow: { + label: "Workflow stages", + scope: "workflow stages", + }, +}; export class FastModeSelectorComponent { private selectedRowIndex = 0; @@ -29,7 +40,7 @@ export class FastModeSelectorComponent { invalidate(): void {} render(width: number): string[] { - const lines: string[] = [theme.bold(theme.fg("accent", "Codex fast mode")), ""]; + const lines: string[] = [truncateToWidth(theme.bold(theme.fg("accent", "Codex fast mode")), width)]; for (const line of wrapTextWithAnsi(DESCRIPTION, Math.max(20, width))) { lines.push(theme.fg("muted", line)); } @@ -38,7 +49,7 @@ export class FastModeSelectorComponent { lines.push(this.renderRow(row, width)); } lines.push(""); - lines.push(truncateToWidth(theme.fg("dim", "tab/↑↓ row · ← enable · → disable · esc close"), width)); + lines.push(truncateToWidth(this.renderHint(), width)); return lines.map((line) => truncateToWidth(line, width)); } @@ -51,12 +62,16 @@ export class FastModeSelectorComponent { this.moveRow(-1); return; } + if (matchesKey(data, "enter") || data === " ") { + this.toggleCurrentRow(); + return; + } if (matchesKey(data, "left")) { - this.setCurrentRow(true); + this.setCurrentRow(false); return; } if (matchesKey(data, "right")) { - this.setCurrentRow(false); + this.setCurrentRow(true); return; } if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { @@ -85,27 +100,33 @@ export class FastModeSelectorComponent { this.callbacks.onChange({ ...this.state }, row); } + private toggleCurrentRow(): void { + const row = this.getFocusedRow(); + this.setCurrentRow(!this.state[row]); + } + + private renderHint(): string { + const sep = theme.fg("dim", " · "); + const hint = (key: string, label: string): string => theme.fg("dim", key) + theme.fg("muted", ` ${label}`); + return [hint("↑↓/tab", "row"), hint("space/enter", "toggle"), hint("esc", "close")].join(sep); + } + private renderRow(row: FastModeRow, width: number): string { const selected = this.getFocusedRow() === row; + const detail = ROW_DETAILS[row]; const prefix = selected ? theme.fg("accent", "› ") : " "; - const label = row.padEnd(8, " "); + const label = detail.label.padEnd(LABEL_WIDTH, " "); const labelText = selected ? theme.bold(theme.fg("accent", label)) : theme.fg("text", label); - const enabledText = this.renderValue(row, true); - const disabledText = this.renderValue(row, false); - return truncateToWidth(`${prefix}${labelText} ${enabledText} ${disabledText}`, width); + const scope = selected ? theme.fg("muted", detail.scope) : theme.fg("dim", detail.scope); + return truncateToWidth(`${prefix}${labelText} ${this.renderToggle(row)} ${scope}`, width); } - private renderValue(row: FastModeRow, enabled: boolean): string { - const value = enabled ? "enabled" : "disabled"; - const selected = this.getFocusedRow() === row; - const active = this.state[row] === enabled; - const text = active ? `[${value}]` : ` ${value} `; - if (selected && active) { - return theme.bold(theme.fg("accent", text)); - } - if (active) { - return theme.fg("text", text); + private renderToggle(row: FastModeRow): string { + const enabled = this.state[row]; + const text = enabled ? "[● ON ]" : "[○ OFF]"; + if (enabled) { + return theme.bold(theme.fg("success", text)); } - return theme.fg("dim", text); + return this.getFocusedRow() === row ? theme.fg("muted", text) : theme.fg("dim", text); } } diff --git a/packages/coding-agent/src/modes/interactive/components/footer.ts b/packages/coding-agent/src/modes/interactive/components/footer.ts index caa5378a8..767f56d31 100644 --- a/packages/coding-agent/src/modes/interactive/components/footer.ts +++ b/packages/coding-agent/src/modes/interactive/components/footer.ts @@ -226,13 +226,13 @@ export class FooterComponent implements Component { this.session.orchestrationContext, ) : false; - const fastModelName = formatCodexFastModeModelLabel(modelName, fastModeEnabled); - let modelLabel = fastModelName; + let modelLabel = modelName; if (state.model?.reasoning) { const thinkingLevel = state.thinkingLevel || "off"; modelLabel = - thinkingLevel === "off" ? fastModelName : `${fastModelName} ${thinkingLevel}`; + thinkingLevel === "off" ? modelLabel : `${modelLabel} ${thinkingLevel}`; } + modelLabel = formatCodexFastModeModelLabel(modelLabel, fastModeEnabled); if (this.footerData.getAvailableProviderCount() > 1 && state.model) { modelLabel = `(${state.model.provider}) ${modelLabel}`; } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index ff160c668..5771f3222 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -54,6 +54,7 @@ import { CHANGELOG_URL, ENV_OFFLINE, getEnvValue, + setCodexFastModeEnvironmentSettings, getAgentDir, getAuthPath, getDebugLogPath, @@ -96,7 +97,9 @@ import { resolveModelScope, } from "../../core/model-resolver.ts"; import { + formatCodexFastModeModelLabel, hasSupportedCodexFastModeModel, + shouldApplyCodexFastMode, } from "../../core/codex-fast-mode.ts"; import { configureHttpDispatcher } from "../../core/http-dispatcher.ts"; import { DefaultPackageManager } from "../../core/package-manager.ts"; @@ -1088,6 +1091,26 @@ export class InteractiveMode { return this.formatDisplayPath(absolutePath); } + private getStartupModelLabel(): string { + const model = this.session.state.model; + let modelLabel = model?.id ?? "no-model"; + + if (model?.reasoning) { + modelLabel = `${modelLabel} ${this.session.thinkingLevel || "off"}`; + } + + if (!model) { + return modelLabel; + } + + const fastModeEnabled = shouldApplyCodexFastMode( + model, + this.session.settingsManager.getCodexFastModeSettings(), + this.session.orchestrationContext, + ); + return formatCodexFastModeModelLabel(modelLabel, fastModeEnabled); + } + private getStartupIdentityText(): string { const appLabel = APP_NAME.length > 0 ? `${APP_NAME[0]!.toUpperCase()}${APP_NAME.slice(1)}` @@ -1095,8 +1118,7 @@ export class InteractiveMode { const title = `${theme.bold(theme.fg("text", appLabel))} ${theme.fg("muted", `v${this.version}`)}`; const model = this.session.state.model; const provider = model ? theme.fg("dim", `(${model.provider})`) : theme.fg("dim", "(no-provider)"); - const thinking = model?.reasoning ? ` ${this.session.thinkingLevel || "off"}` : ""; - const modelLine = `${provider} ${theme.fg("muted", `${model?.id ?? "no-model"}${thinking}`)}`; + const modelLine = `${provider} ${theme.fg("muted", this.getStartupModelLabel())}`; const cwd = theme.fg("muted", this.formatDisplayPath(this.sessionManager.getCwd())); const metaLines = [title, modelLine, cwd]; const markLines = this.getAtomicAnsiMarkLines(); @@ -4469,18 +4491,26 @@ export class InteractiveMode { } this.showSelector((done) => { + let pendingStatusMessage: string | undefined; const selector = new FastModeSelectorComponent( this.settingsManager.getCodexFastModeSettings(), { onChange: (settings, changedRow) => { this.settingsManager.setCodexFastModeSettings({ [changedRow]: settings[changedRow] }); - this.showStatus( - `Codex fast mode: chat ${settings.chat ? "enabled" : "disabled"}, workflow ${settings.workflow ? "enabled" : "disabled"}`, - ); + const effectiveSettings = this.settingsManager.getCodexFastModeSettings(); + setCodexFastModeEnvironmentSettings(effectiveSettings); + this.footer.invalidate(); + this.refreshBuiltInHeader(); + const changedLabel = changedRow === "chat" ? "Chat" : "Workflow"; + const changedState = effectiveSettings[changedRow] ? "on" : "off"; + pendingStatusMessage = `${changedLabel} fast mode ${changedState}`; }, onCancel: async () => { await this.settingsManager.flush(); done(); + if (pendingStatusMessage) { + this.showStatus(pendingStatusMessage); + } this.ui.requestRender(); }, }, diff --git a/packages/coding-agent/test/codex-fast-mode.test.ts b/packages/coding-agent/test/codex-fast-mode.test.ts index 18f7abbe7..fd9f2cda1 100644 --- a/packages/coding-agent/test/codex-fast-mode.test.ts +++ b/packages/coding-agent/test/codex-fast-mode.test.ts @@ -1,7 +1,17 @@ -import type { Api, Model } from "@earendil-works/pi-ai"; +import { + createAssistantMessageEventStream, + type Api, + type AssistantMessageEventStream, + type Context, + type Model, + type OpenAICodexResponsesOptions, + type OpenAIResponsesOptions, + type SimpleStreamOptions, +} from "@earendil-works/pi-ai"; import { describe, expect, it } from "vitest"; import { CODEX_FAST_MODE_SERVICE_TIER, + type CodexFastModeStreamers, getCodexFastModeScope, hasSupportedCodexFastModeModel, isCodexFastModeEnabledForScope, @@ -9,15 +19,63 @@ import { isCodexFastModeCandidateModelId, isCodexFastModeSupportedProvider, shouldApplyCodexFastModeForScope, + streamWithCodexFastMode, withCodexFastModePayload, withCodexFastModeStreamOptions, } from "../src/core/codex-fast-mode.ts"; import type { OrchestrationContext } from "../src/core/extensions/index.ts"; -function model(provider: string): Pick, "provider"> { +function providerModel(provider: string): Pick, "provider"> { return { provider }; } +function fullModel(partial: Partial>): Model { + return { + id: "gpt-5.1-codex", + name: "GPT-5.1 Codex", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.example/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 4096, + ...partial, + }; +} + +interface CapturedStreamCall { + name: keyof CodexFastModeStreamers; + model: Model; + options?: SimpleStreamOptions | OpenAIResponsesOptions | OpenAICodexResponsesOptions; +} + +function doneStream(): AssistantMessageEventStream { + const stream = createAssistantMessageEventStream(); + stream.end(); + return stream; +} + +function makeStreamers(calls: CapturedStreamCall[]): CodexFastModeStreamers { + return { + streamSimple: (streamModel, _context, options) => { + calls.push({ name: "streamSimple", model: streamModel, options }); + return doneStream(); + }, + streamOpenAIResponses: (streamModel, _context, options) => { + calls.push({ name: "streamOpenAIResponses", model: streamModel, options }); + return doneStream(); + }, + streamOpenAICodexResponses: (streamModel, _context, options) => { + calls.push({ name: "streamOpenAICodexResponses", model: streamModel, options }); + return doneStream(); + }, + }; +} + +const emptyContext: Context = { messages: [] }; + const workflowContext: OrchestrationContext = { kind: "workflow-stage", workflowRunId: "run-1", @@ -38,9 +96,9 @@ describe("codex fast mode helpers", () => { }); it("detects supported models from provider IDs", () => { - expect(hasSupportedCodexFastModeModel([model("github-copilot")])).toBe(false); - expect(hasSupportedCodexFastModeModel([model("github-copilot"), model("openai")])).toBe(true); - expect(hasSupportedCodexFastModeModel([model("openai-codex")])).toBe(true); + expect(hasSupportedCodexFastModeModel([providerModel("github-copilot")])).toBe(false); + expect(hasSupportedCodexFastModeModel([providerModel("github-copilot"), providerModel("openai")])).toBe(true); + expect(hasSupportedCodexFastModeModel([providerModel("openai-codex")])).toBe(true); }); it("detects candidate model ids with the shared provider policy", () => { @@ -59,8 +117,8 @@ describe("codex fast mode helpers", () => { expect(isCodexFastModeEnabledForSession({ chat: true, workflow: false }, undefined)).toBe(true); expect(isCodexFastModeEnabledForSession({ chat: true, workflow: false }, workflowContext)).toBe(false); expect(isCodexFastModeEnabledForSession({ chat: false, workflow: true }, workflowContext)).toBe(true); - expect(shouldApplyCodexFastModeForScope(model("openai"), { chat: false, workflow: true }, "workflow")).toBe(true); - expect(shouldApplyCodexFastModeForScope(model("github-copilot"), { chat: false, workflow: true }, "workflow")).toBe(false); + expect(shouldApplyCodexFastModeForScope(providerModel("openai"), { chat: false, workflow: true }, "workflow")).toBe(true); + expect(shouldApplyCodexFastModeForScope(providerModel("github-copilot"), { chat: false, workflow: true }, "workflow")).toBe(false); }); it("adds serviceTier to stream options only when enabled", () => { @@ -85,4 +143,76 @@ describe("codex fast mode helpers", () => { service_tier: CODEX_FAST_MODE_SERVICE_TIER, }); }); + + it("uses native OpenAI Responses streaming when fast mode is active", () => { + const calls: CapturedStreamCall[] = []; + const streamers = makeStreamers(calls); + const options = withCodexFastModeStreamOptions( + { apiKey: "key", reasoning: "medium", sessionId: "session-1" }, + true, + ); + + streamWithCodexFastMode( + fullModel({ api: "openai-responses", provider: "openai" }), + emptyContext, + options, + streamers, + ); + + expect(calls).toHaveLength(1); + expect(calls[0]?.name).toBe("streamOpenAIResponses"); + const providerOptions = calls[0]?.options as OpenAIResponsesOptions | undefined; + expect(providerOptions?.serviceTier).toBe(CODEX_FAST_MODE_SERVICE_TIER); + expect(providerOptions?.reasoningEffort).toBe("medium"); + expect(providerOptions?.apiKey).toBe("key"); + expect(providerOptions?.sessionId).toBe("session-1"); + }); + + it("uses native OpenAI Codex Responses streaming when fast mode is active", () => { + const calls: CapturedStreamCall[] = []; + const streamers = makeStreamers(calls); + const options = withCodexFastModeStreamOptions( + { apiKey: "key", reasoning: "xhigh", transport: "sse" }, + true, + ); + + streamWithCodexFastMode( + fullModel({ + api: "openai-codex-responses", + provider: "openai-codex", + id: "gpt-5.5", + thinkingLevelMap: { xhigh: "xhigh" }, + }), + emptyContext, + options, + streamers, + ); + + expect(calls).toHaveLength(1); + expect(calls[0]?.name).toBe("streamOpenAICodexResponses"); + const providerOptions = calls[0]?.options as OpenAICodexResponsesOptions | undefined; + expect(providerOptions?.serviceTier).toBe(CODEX_FAST_MODE_SERVICE_TIER); + expect(providerOptions?.reasoningEffort).toBe("xhigh"); + expect(providerOptions?.transport).toBe("sse"); + }); + + it("falls back to the normal simple streamer when native fast mode should not apply", () => { + const calls: CapturedStreamCall[] = []; + const streamers = makeStreamers(calls); + + streamWithCodexFastMode( + fullModel({ api: "openai-responses", provider: "openai" }), + emptyContext, + withCodexFastModeStreamOptions({ apiKey: "key" }, false), + streamers, + ); + streamWithCodexFastMode( + fullModel({ api: "openai-responses", provider: "github-copilot" }), + emptyContext, + withCodexFastModeStreamOptions({ apiKey: "key" }, true), + streamers, + ); + + expect(calls.map((call) => call.name)).toEqual(["streamSimple", "streamSimple"]); + }); }); diff --git a/packages/coding-agent/test/fast-mode-selector.test.ts b/packages/coding-agent/test/fast-mode-selector.test.ts index b51a47bb4..a806413b5 100644 --- a/packages/coding-agent/test/fast-mode-selector.test.ts +++ b/packages/coding-agent/test/fast-mode-selector.test.ts @@ -20,11 +20,17 @@ describe("FastModeSelectorComponent", () => { const rendered = plainRender(selector); expect(rendered).toContain("Codex fast mode"); - expect(rendered).toContain("chat"); - expect(rendered).toContain("workflow"); - expect(rendered).toContain("[disabled]"); - expect(rendered).toContain("[enabled]"); - expect(rendered).toContain("← enable · → disable"); + expect(rendered).toContain("Priority tier for supported openai/* and openai-codex/* models."); + expect(rendered).toContain("Chat"); + expect(rendered).toContain("Workflow stages"); + expect(rendered).toContain("[○ OFF]"); + expect(rendered).toContain("[● ON ]"); + expect(rendered).not.toContain("Chat off · Workflow on"); + expect(rendered).toContain("this chat + subagents"); + expect(rendered).toContain("space/enter toggle"); + expect(rendered).not.toContain("← off · → on"); + expect(rendered).not.toContain("standard tier"); + expect(rendered.split("\n")).toHaveLength(7); }); it("moves rows with tab and shift-tab", () => { @@ -41,7 +47,7 @@ describe("FastModeSelectorComponent", () => { expect(selector.getFocusedRow()).toBe("chat"); }); - it("changes the focused row with left and right arrows", () => { + it("changes the focused row with arrows and toggle keys", () => { initTheme("dark"); const onChange = vi.fn(); const selector = new FastModeSelectorComponent( @@ -49,18 +55,22 @@ describe("FastModeSelectorComponent", () => { { onChange, onCancel: () => {} }, ); - selector.handleInput("\x1b[D"); + selector.handleInput("\x1b[C"); expect(selector.getSettings()).toEqual({ chat: true, workflow: false }); expect(onChange).toHaveBeenLastCalledWith({ chat: true, workflow: false }, "chat"); - selector.handleInput("\t"); - selector.handleInput("\x1b[D"); - expect(selector.getSettings()).toEqual({ chat: true, workflow: true }); - expect(onChange).toHaveBeenLastCalledWith({ chat: true, workflow: true }, "workflow"); + selector.handleInput(" "); + expect(selector.getSettings()).toEqual({ chat: false, workflow: false }); + expect(onChange).toHaveBeenLastCalledWith({ chat: false, workflow: false }, "chat"); + selector.handleInput("\t"); selector.handleInput("\x1b[C"); - expect(selector.getSettings()).toEqual({ chat: true, workflow: false }); - expect(onChange).toHaveBeenLastCalledWith({ chat: true, workflow: false }, "workflow"); + expect(selector.getSettings()).toEqual({ chat: false, workflow: true }); + expect(onChange).toHaveBeenLastCalledWith({ chat: false, workflow: true }, "workflow"); + + selector.handleInput("\x1b[D"); + expect(selector.getSettings()).toEqual({ chat: false, workflow: false }); + expect(onChange).toHaveBeenLastCalledWith({ chat: false, workflow: false }, "workflow"); }); it("cancels on escape", () => { diff --git a/packages/coding-agent/test/footer-codex-fast-mode.test.ts b/packages/coding-agent/test/footer-codex-fast-mode.test.ts index 13bacbe2c..b902f6775 100644 --- a/packages/coding-agent/test/footer-codex-fast-mode.test.ts +++ b/packages/coding-agent/test/footer-codex-fast-mode.test.ts @@ -1,3 +1,4 @@ +import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { describe, expect, it } from "vitest"; import type { AgentSession } from "../src/core/agent-session.ts"; import type { OrchestrationContext } from "../src/core/extensions/types.ts"; @@ -17,11 +18,20 @@ const workflowContext: OrchestrationContext = { constraints: { disableWorkflowTool: true, maxSubagentDepth: 0 }, }; -function sessionWithFastMode(chat: boolean, workflow = false, orchestrationContext?: OrchestrationContext): AgentSession { +function sessionWithFastMode( + chat: boolean, + workflow = false, + orchestrationContext?: OrchestrationContext, + options: { reasoning?: boolean; thinkingLevel?: ThinkingLevel } = {}, +): AgentSession { return { state: { - model: { provider: "openai", id: "gpt-5.1-codex" }, - thinkingLevel: "off", + model: { + provider: "openai", + id: "gpt-5.1-codex", + reasoning: options.reasoning ?? false, + }, + thinkingLevel: options.thinkingLevel ?? "off", }, settingsManager: { getCodexFastModeSettings: () => ({ chat, workflow }), @@ -54,6 +64,21 @@ describe("FooterComponent Codex fast mode indicator", () => { expect(plain(footer.render(120)[0])).not.toContain("fast"); }); + it("shows fast after the reasoning level when both are present", () => { + initTheme("dark"); + const footer = new FooterComponent( + sessionWithFastMode(true, false, undefined, { + reasoning: true, + thinkingLevel: "medium", + }), + footerData, + ); + const rendered = plain(footer.render(120)[0]); + + expect(rendered).toContain("gpt-5.1-codex medium fast"); + expect(rendered).not.toContain("gpt-5.1-codex fast medium"); + }); + it("uses workflow scope for workflow-stage session footers", () => { initTheme("dark"); const footer = new FooterComponent(sessionWithFastMode(false, true, workflowContext), footerData); diff --git a/packages/coding-agent/test/interactive-mode-startup-banner.test.ts b/packages/coding-agent/test/interactive-mode-startup-banner.test.ts new file mode 100644 index 000000000..aded55300 --- /dev/null +++ b/packages/coding-agent/test/interactive-mode-startup-banner.test.ts @@ -0,0 +1,115 @@ +import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; +import { describe, expect, it, vi } from "vitest"; +import { ENV_CODEX_FAST_MODE } from "../src/config.ts"; +import type { AgentSession } from "../src/core/agent-session.ts"; +import { FastModeSelectorComponent } from "../src/modes/interactive/components/fast-mode-selector.ts"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; + +function plain(text: string): string { + return text.replace(/\u001b\[[0-9;]*m/g, ""); +} + +interface StartupIdentityAccess { + getStartupIdentityText(): string; +} + +interface FastModeSelectorAccess { + showFastModeSelector(): void; +} + +function renderStartupIdentity(options: { + chatFastMode: boolean; + reasoning: boolean; + thinkingLevel: ThinkingLevel; +}): string { + const session = { + state: { + model: { + provider: "openai", + id: "gpt-5.1-codex", + reasoning: options.reasoning, + }, + thinkingLevel: options.thinkingLevel, + }, + thinkingLevel: options.thinkingLevel, + settingsManager: { + getCodexFastModeSettings: () => ({ + chat: options.chatFastMode, + workflow: false, + }), + }, + orchestrationContext: undefined, + sessionManager: { + getCwd: () => "/tmp/project", + }, + } as unknown as AgentSession; + const mode = Object.assign(Object.create(InteractiveMode.prototype), { + version: "0.0.0", + runtimeHost: { session }, + }); + + return plain((mode as StartupIdentityAccess).getStartupIdentityText()); +} + +describe("InteractiveMode startup banner", () => { + it("shows fast after the reasoning level when chat fast mode applies", () => { + initTheme("dark"); + const rendered = renderStartupIdentity({ + chatFastMode: true, + reasoning: true, + thinkingLevel: "medium", + }); + + expect(rendered).toContain("(openai) gpt-5.1-codex medium fast"); + expect(rendered).not.toContain("gpt-5.1-codex fast medium"); + }); + + it("refreshes the banner and inherited child fast-mode state when /fast changes", async () => { + initTheme("dark"); + const previous = process.env[ENV_CODEX_FAST_MODE]; + let settings = { chat: false, workflow: false }; + let selector: FastModeSelectorComponent | undefined; + const settingsManager = { + flush: vi.fn(), + getCodexFastModeSettings: () => settings, + setCodexFastModeSettings: vi.fn((next: Partial) => { + settings = { ...settings, ...next }; + }), + }; + const fakeMode = Object.assign(Object.create(InteractiveMode.prototype), { + footer: { invalidate: vi.fn() }, + hasCodexFastModeSupportedModels: () => true, + refreshBuiltInHeader: vi.fn(), + runtimeHost: { session: { settingsManager } }, + showSelector: (create: (done: () => void) => { component: FastModeSelectorComponent }) => { + selector = create(() => {}).component; + }, + showStatus: vi.fn(), + ui: { requestRender: vi.fn() }, + }); + + try { + (fakeMode as unknown as FastModeSelectorAccess).showFastModeSelector(); + selector?.handleInput("\x1b[C"); + + expect(settingsManager.setCodexFastModeSettings).toHaveBeenCalledWith({ chat: true }); + expect(fakeMode.footer.invalidate).toHaveBeenCalledTimes(1); + expect(fakeMode.refreshBuiltInHeader).toHaveBeenCalledTimes(1); + expect(fakeMode.showStatus).not.toHaveBeenCalled(); + expect(process.env[ENV_CODEX_FAST_MODE]).toBe("chat=1;workflow=0"); + + selector?.handleInput("\x1b"); + await Promise.resolve(); + + expect(settingsManager.flush).toHaveBeenCalledTimes(1); + expect(fakeMode.showStatus).toHaveBeenCalledWith("Chat fast mode on"); + } finally { + if (previous === undefined) { + delete process.env[ENV_CODEX_FAST_MODE]; + } else { + process.env[ENV_CODEX_FAST_MODE] = previous; + } + } + }); +}); diff --git a/packages/coding-agent/test/sdk-codex-fast-mode.test.ts b/packages/coding-agent/test/sdk-codex-fast-mode.test.ts index 5ec5eeac7..b3716d019 100644 --- a/packages/coding-agent/test/sdk-codex-fast-mode.test.ts +++ b/packages/coding-agent/test/sdk-codex-fast-mode.test.ts @@ -8,7 +8,8 @@ import { type Model, type SimpleStreamOptions, } from "@earendil-works/pi-ai"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ENV_CODEX_FAST_MODE } from "../src/config.ts"; import { AuthStorage } from "../src/core/auth-storage.ts"; import { CODEX_FAST_MODE_SERVICE_TIER } from "../src/core/codex-fast-mode.ts"; import type { OrchestrationContext } from "../src/core/extensions/index.ts"; @@ -37,6 +38,15 @@ function createModel(provider: string, api: Api): Model { }; } +async function bodyToText(body: BodyInit | null | undefined): Promise { + if (body === null || body === undefined) return ""; + if (typeof body === "string") return body; + if (body instanceof URLSearchParams) return body.toString(); + if (body instanceof Blob) return body.text(); + if (body instanceof ArrayBuffer) return new TextDecoder().decode(body); + return new Response(body).text(); +} + function createDoneStream(model: Model) { const stream = createAssistantMessageEventStream(); const message: AssistantMessage = { @@ -87,6 +97,7 @@ describe("createAgentSession codex fast mode", () => { }); afterEach(() => { + vi.unstubAllGlobals(); for (const entry of registeredProviders.reverse()) { entry.registry.unregisterProvider(entry.provider); } @@ -153,6 +164,28 @@ describe("createAgentSession codex fast mode", () => { expect(captured.payload).toMatchObject({ service_tier: CODEX_FAST_MODE_SERVICE_TIER }); }); + it("applies inherited chat fast mode environment to child sessions", async () => { + const previous = process.env[ENV_CODEX_FAST_MODE]; + process.env[ENV_CODEX_FAST_MODE] = "chat=1;workflow=0"; + try { + const captured = await captureFastModeRequest({ + provider: "openai", + settings: { chat: false, workflow: false }, + }); + + expect((captured.options as SimpleStreamOptions & { serviceTier?: string })?.serviceTier).toBe( + CODEX_FAST_MODE_SERVICE_TIER, + ); + expect(captured.payload).toMatchObject({ service_tier: CODEX_FAST_MODE_SERVICE_TIER }); + } finally { + if (previous === undefined) { + delete process.env[ENV_CODEX_FAST_MODE]; + } else { + process.env[ENV_CODEX_FAST_MODE] = previous; + } + } + }); + it("uses the workflow setting for workflow-stage requests", async () => { const disabled = await captureFastModeRequest({ provider: "openai-codex", @@ -183,6 +216,46 @@ describe("createAgentSession codex fast mode", () => { expect(captured.payload).not.toMatchObject({ service_tier: CODEX_FAST_MODE_SERVICE_TIER }); }); + it("sends priority service tier in native OpenAI Responses request bodies", async () => { + const model = createModel("openai", "openai-responses"); + const authStorage = AuthStorage.create(join(agentDir, "auth.json")); + authStorage.setRuntimeApiKey("openai", "test-api-key"); + const modelRegistry = ModelRegistry.create(authStorage, join(agentDir, "models.json")); + const settingsManager = SettingsManager.inMemory({ codexFastMode: { chat: true, workflow: false } }); + const sessionManager = SessionManager.inMemory(cwd); + let capturedPayload: Record | undefined; + vi.stubGlobal( + "fetch", + vi.fn(async (_input: RequestInfo | URL, init?: RequestInit): Promise => { + capturedPayload = JSON.parse(await bodyToText(init?.body)) as Record; + return new Response("data: [DONE]\n\n", { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }), + ); + + const { session } = await createAgentSession({ + cwd, + agentDir, + model, + authStorage, + modelRegistry, + settingsManager, + sessionManager, + }); + + try { + const stream = await session.agent.streamFn(model, { messages: [] }, { sessionId: session.sessionId }); + const result = await stream.result(); + + expect(result.stopReason).toBe("stop"); + expect(capturedPayload).toMatchObject({ service_tier: CODEX_FAST_MODE_SERVICE_TIER }); + } finally { + session.dispose(); + } + }); + it("does not overwrite an existing provider payload service_tier", async () => { const captured = await captureFastModeRequest({ provider: "openai", diff --git a/packages/coding-agent/test/settings-manager-codex-fast-mode.test.ts b/packages/coding-agent/test/settings-manager-codex-fast-mode.test.ts index c66709c40..614394525 100644 --- a/packages/coding-agent/test/settings-manager-codex-fast-mode.test.ts +++ b/packages/coding-agent/test/settings-manager-codex-fast-mode.test.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { ENV_CODEX_FAST_MODE } from "../src/config.ts"; import { SettingsManager } from "../src/core/settings-manager.ts"; describe("SettingsManager codexFastMode", () => { @@ -78,6 +79,46 @@ describe("SettingsManager codexFastMode", () => { expect(savedProject.codexFastMode).toEqual({ workflow: true }); }); + it("honors inherited runtime fast mode settings over persisted settings", () => { + const previous = process.env[ENV_CODEX_FAST_MODE]; + process.env[ENV_CODEX_FAST_MODE] = "chat=1;workflow=0"; + try { + writeFileSync( + join(agentDir, "settings.json"), + JSON.stringify({ codexFastMode: { chat: false, workflow: true } }, null, 2), + ); + + const manager = SettingsManager.create(cwd, agentDir); + + expect(manager.getCodexFastModeSettings()).toEqual({ chat: true, workflow: false }); + } finally { + if (previous === undefined) { + delete process.env[ENV_CODEX_FAST_MODE]; + } else { + process.env[ENV_CODEX_FAST_MODE] = previous; + } + } + }); + + it("updates runtime fast mode overrides when settings change", async () => { + const previous = process.env[ENV_CODEX_FAST_MODE]; + process.env[ENV_CODEX_FAST_MODE] = "chat=0;workflow=0"; + try { + const manager = SettingsManager.inMemory(); + + manager.setCodexFastModeSettings({ chat: true }); + await manager.flush(); + + expect(manager.getCodexFastModeSettings()).toEqual({ chat: true, workflow: false }); + } finally { + if (previous === undefined) { + delete process.env[ENV_CODEX_FAST_MODE]; + } else { + process.env[ENV_CODEX_FAST_MODE] = previous; + } + } + }); + it("does not clobber untouched global fast mode fields with project overrides", async () => { writeFileSync( join(agentDir, "settings.json"), From bbbb625b1faa67caf3782a91b00e1126539b6771 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Sun, 31 May 2026 01:23:24 -0700 Subject: [PATCH 16/16] fix(codex-fast-mode): preserve registered provider streams Assistant-model: GPT-5.5 --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/model-registry.ts | 12 +++++ packages/coding-agent/src/core/sdk.ts | 37 +++++++------- .../test/sdk-codex-fast-mode.test.ts | 50 +++++++++++++++++++ 4 files changed, 82 insertions(+), 18 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ff1ef6792..00db40091 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -13,6 +13,7 @@ ### Fixed +- Preserved custom registered provider streamers when Codex fast mode is enabled for native OpenAI response APIs ([#1134](https://github.com/flora131/atomic/issues/1134)). - Fixed `/fast` changes so the banner/footer and current session update immediately, and inherited chat fast-mode state now reaches subagent child sessions without waiting for a restart ([#1134](https://github.com/flora131/atomic/issues/1134)). - Fixed `/fast` persistence so existing project-level fast-mode overrides are updated alongside global settings for the changed scope without clobbering untouched global chat or workflow fast-mode preferences ([#1134](https://github.com/flora131/atomic/issues/1134)). - Made Codex fast-mode request helpers require an explicit enabled flag and treat `service_tier: undefined` as unset when preparing OpenAI payloads ([#1134](https://github.com/flora131/atomic/issues/1134)). diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index 9b63b62f1..7d70756c4 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -824,6 +824,18 @@ export class ModelRegistry { this.upsertRegisteredProvider(providerName, config); } + /** + * Check whether extensions have registered custom streamSimple dispatch for an API. + */ + hasRegisteredStreamSimpleForApi(api: Api): boolean { + for (const config of this.registeredProviders.values()) { + if (config.api === api && config.streamSimple) { + return true; + } + } + return false; + } + /** * Unregister a previously registered provider. * diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 883f996da..60ea68915 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -9,6 +9,7 @@ import { type Api, type Message, type Model, + streamSimple, } from "@earendil-works/pi-ai"; import { APP_NAME, getAgentDir } from "../config.ts"; import { resolvePath } from "../utils/paths.ts"; @@ -416,25 +417,25 @@ export async function createAgentSession( const providerRetrySettings = settingsManager.getProviderRetrySettings(); const attributionHeaders = getAttributionHeaders(model, settingsManager, streamOptions?.sessionId); const fastModeEnabled = isCodexFastModeEnabled(model); - return streamWithCodexFastMode( - model, - context, - withCodexFastModeStreamOptions( - { - ...streamOptions, - apiKey: auth.apiKey, - timeoutMs: streamOptions?.timeoutMs ?? providerRetrySettings.timeoutMs, - maxRetries: streamOptions?.maxRetries ?? providerRetrySettings.maxRetries, - maxRetryDelayMs: - streamOptions?.maxRetryDelayMs ?? providerRetrySettings.maxRetryDelayMs, - headers: - attributionHeaders || auth.headers || streamOptions?.headers - ? { ...attributionHeaders, ...auth.headers, ...streamOptions?.headers } - : undefined, - }, - fastModeEnabled, - ), + const codexFastModeStreamOptions = withCodexFastModeStreamOptions( + { + ...streamOptions, + apiKey: auth.apiKey, + timeoutMs: streamOptions?.timeoutMs ?? providerRetrySettings.timeoutMs, + maxRetries: streamOptions?.maxRetries ?? providerRetrySettings.maxRetries, + maxRetryDelayMs: + streamOptions?.maxRetryDelayMs ?? providerRetrySettings.maxRetryDelayMs, + headers: + attributionHeaders || auth.headers || streamOptions?.headers + ? { ...attributionHeaders, ...auth.headers, ...streamOptions?.headers } + : undefined, + }, + fastModeEnabled, ); + if (modelRegistry.hasRegisteredStreamSimpleForApi(model.api)) { + return streamSimple(model, context, codexFastModeStreamOptions); + } + return streamWithCodexFastMode(model, context, codexFastModeStreamOptions); }, onPayload: async (payload, model) => { const fastModeEnabled = isCodexFastModeEnabled(model); diff --git a/packages/coding-agent/test/sdk-codex-fast-mode.test.ts b/packages/coding-agent/test/sdk-codex-fast-mode.test.ts index b3716d019..864b38587 100644 --- a/packages/coding-agent/test/sdk-codex-fast-mode.test.ts +++ b/packages/coding-agent/test/sdk-codex-fast-mode.test.ts @@ -164,6 +164,56 @@ describe("createAgentSession codex fast mode", () => { expect(captured.payload).toMatchObject({ service_tier: CODEX_FAST_MODE_SERVICE_TIER }); }); + it("preserves custom provider streaming for native OpenAI APIs when fast mode is enabled", async () => { + const model = createModel("openai", "openai-responses"); + const authStorage = AuthStorage.create(join(agentDir, "auth.json")); + authStorage.setRuntimeApiKey("openai", "test-api-key"); + const modelRegistry = ModelRegistry.create(authStorage, join(agentDir, "models.json")); + const settingsManager = SettingsManager.inMemory({ codexFastMode: { chat: true, workflow: false } }); + const sessionManager = SessionManager.inMemory(cwd); + let capturedOptions: SimpleStreamOptions | undefined; + const nativeFetch = vi.fn(async (): Promise => { + throw new Error("native OpenAI streaming should not be called for registered providers"); + }); + vi.stubGlobal("fetch", nativeFetch); + + modelRegistry.registerProvider("openai", { + api: "openai-responses", + streamSimple: (_model, _context, streamOptions) => { + capturedOptions = streamOptions; + return createDoneStream(model); + }, + }); + registeredProviders.push({ registry: modelRegistry, provider: "openai" }); + + const { session } = await createAgentSession({ + cwd, + agentDir, + model, + authStorage, + modelRegistry, + settingsManager, + sessionManager, + }); + + try { + const stream = await session.agent.streamFn(model, { messages: [] }, { sessionId: session.sessionId }); + const result = await stream.result(); + + expect(result.stopReason).toBe("stop"); + expect(nativeFetch).not.toHaveBeenCalled(); + expect((capturedOptions as SimpleStreamOptions & { serviceTier?: string })?.serviceTier).toBe( + CODEX_FAST_MODE_SERVICE_TIER, + ); + } finally { + session.dispose(); + modelRegistry.unregisterProvider("openai"); + registeredProviders = registeredProviders.filter( + (entry) => entry.registry !== modelRegistry || entry.provider !== "openai", + ); + } + }); + it("applies inherited chat fast mode environment to child sessions", async () => { const previous = process.env[ENV_CODEX_FAST_MODE]; process.env[ENV_CODEX_FAST_MODE] = "chat=1;workflow=0";