diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0225b8e84..00db40091 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,23 @@ ## [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; 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 + +- 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)). +- 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 ### Changed diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index 538b9798c..6725a5599 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. 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 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..fe711710a 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. 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 | +|---------|------|---------|-------------| +| `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/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/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/core/codex-fast-mode.ts b/packages/coding-agent/src/core/codex-fast-mode.ts new file mode 100644 index 000000000..bb435ff75 --- /dev/null +++ b/packages/coding-agent/src/core/codex-fast-mode.ts @@ -0,0 +1,233 @@ +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; + +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 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"; +} + +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); +} + +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 isCodexFastModeEnabledForScope( + settings: CodexFastModeResolvedSettings, + scope: CodexFastModeScope, +): boolean { + return settings[scope]; +} + +export function isCodexFastModeEnabledForSession( + settings: CodexFastModeResolvedSettings, + context: OrchestrationContext | undefined, +): boolean { + 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( + model: Pick, "provider">, + settings: CodexFastModeResolvedSettings, + context: OrchestrationContext | undefined, +): boolean { + return shouldApplyCodexFastModeForScope(model, settings, getCodexFastModeScope(context)); +} + +export function withCodexFastModeStreamOptions( + options: SimpleStreamOptions | undefined, + enabled: boolean, +): CodexFastModeStreamOptions | undefined { + if (!enabled) { + return options; + } + + return { + ...(options ?? {}), + serviceTier: CODEX_FAST_MODE_SERVICE_TIER, + }; +} + +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); +} + +export function withCodexFastModePayload(payload: unknown, enabled: boolean): unknown { + if (!enabled || !isObjectPayload(payload) || payload.service_tier !== undefined) { + return payload; + } + + return { + ...payload, + 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/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 2bfe04f26..60ea68915 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -16,6 +16,12 @@ 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, + streamWithCodexFastMode, + withCodexFastModePayload, + withCodexFastModeStreamOptions, +} from "./codex-fast-mode.ts"; import { DEFAULT_THINKING_LEVEL } from "./defaults.ts"; import type { ExtensionRunner, @@ -388,6 +394,12 @@ export async function createAgentSession( }; const extensionRunnerRef: { current?: ExtensionRunner } = {}; + const isCodexFastModeEnabled = (requestModel: Model): boolean => + shouldApplyCodexFastMode( + requestModel, + settingsManager.getCodexFastModeSettings(), + options.orchestrationContext, + ); agent = new Agent({ initialState: { @@ -397,32 +409,42 @@ 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 = isCodexFastModeEnabled(model); + 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) => { + onPayload: async (payload, model) => { + const fastModeEnabled = isCodexFastModeEnabled(model); + 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..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, @@ -65,6 +66,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 +126,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 } @@ -276,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 @@ -299,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 */ @@ -461,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 */ @@ -558,7 +580,7 @@ export class SettingsManager { } private save(): void { - this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); + this.settings = this.mergeEffectiveSettings(); if (this.globalSettingsLoadError) { return; @@ -575,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; @@ -1113,4 +1135,59 @@ 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: Partial<{ chat: boolean; workflow: boolean }>): void { + if (settings.chat === undefined && settings.workflow === undefined) { + return; + } + if (!this.globalSettings.codexFastMode) { + this.globalSettings.codexFastMode = {}; + } + 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; + let projectModified = false; + if ((settings.chat !== undefined && projectOverridesChat) || (settings.workflow !== undefined && projectOverridesWorkflow)) { + this.projectSettings.codexFastMode = { ...(projectCodexFastMode ?? {}) }; + if (settings.chat !== undefined && projectOverridesChat) { + this.projectSettings.codexFastMode.chat = settings.chat; + this.markProjectModified("codexFastMode", "chat"); + projectModified = true; + } + if (settings.workflow !== undefined && projectOverridesWorkflow) { + this.projectSettings.codexFastMode.workflow = settings.workflow; + this.markProjectModified("codexFastMode", "workflow"); + projectModified = true; + } + if (projectModified) { + this.saveProjectSettings(this.projectSettings); + } + } + + 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/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/index.ts b/packages/coding-agent/src/index.ts index afdefd373..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, @@ -71,6 +72,20 @@ export { serializeConversation, shouldCompact, } from "./core/compaction/index.ts"; +export { + CODEX_FAST_MODE_SERVICE_TIER, + formatCodexFastModeModelLabel, + getCodexFastModeScope, + hasSupportedCodexFastModeModel, + isCodexFastModeEnabledForScope, + isCodexFastModeCandidateModelId, + isCodexFastModeSupportedModel, + isCodexFastModeSupportedProvider, + shouldApplyCodexFastMode, + shouldApplyCodexFastModeForScope, + 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 new file mode 100644 index 000000000..62e9ba331 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/fast-mode-selector.ts @@ -0,0 +1,132 @@ +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, changedRow: FastModeRow) => void; + onCancel: () => void | Promise; +} + +export type FastModeRow = keyof FastModeSelectorConfig; + +const ROWS: readonly FastModeRow[] = ["chat", "workflow"]; +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; + 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[] = [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)); + } + lines.push(""); + for (const row of ROWS) { + lines.push(this.renderRow(row, width)); + } + lines.push(""); + lines.push(truncateToWidth(this.renderHint(), 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, "enter") || data === " ") { + this.toggleCurrentRow(); + 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 }, 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 = detail.label.padEnd(LABEL_WIDTH, " "); + const labelText = selected ? theme.bold(theme.fg("accent", label)) : theme.fg("text", label); + const scope = selected ? theme.fg("muted", detail.scope) : theme.fg("dim", detail.scope); + return truncateToWidth(`${prefix}${labelText} ${this.renderToggle(row)} ${scope}`, width); + } + + 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 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 3d5b8e347..767f56d31 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,12 +218,21 @@ 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 + ? shouldApplyCodexFastMode( + state.model, + fastModeSettings, + this.session.orchestrationContext, + ) + : false; let modelLabel = modelName; if (state.model?.reasoning) { const thinkingLevel = state.thinkingLevel || "off"; modelLabel = - thinkingLevel === "off" ? modelName : `${modelName} ${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/components/index.ts b/packages/coding-agent/src/modes/interactive/components/index.ts index da5530154..6dad44ebf 100644 --- a/packages/coding-agent/src/modes/interactive/components/index.ts +++ b/packages/coding-agent/src/modes/interactive/components/index.ts @@ -37,6 +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 { 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..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, @@ -95,6 +96,11 @@ import { findExactModelReferenceMatch, 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"; import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "../../core/provider-display-names.ts"; @@ -152,6 +158,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, @@ -253,6 +260,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"); } @@ -542,12 +553,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: @@ -558,15 +566,31 @@ export class InteractiveMode { })); } + private getCodexFastModeCandidateModels(): Model[] { + 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 { + 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", @@ -619,11 +643,13 @@ export class InteractiveMode { }), ); - // Convert extension commands to SlashCommand format - const builtinCommandNames = new Set(slashCommands.map((c) => c.name)); + // 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) => !builtinCommandNames.has(cmd.name)) + .filter((cmd) => !BUILTIN_SLASH_COMMAND_NAMES.has(cmd.name)) .map((cmd) => ({ name: cmd.invocationName, description: this.prefixAutocompleteDescription( @@ -1065,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)}` @@ -1072,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(); @@ -2999,6 +3044,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 +4482,43 @@ 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) => { + let pendingStatusMessage: string | undefined; + const selector = new FastModeSelectorComponent( + this.settingsManager.getCodexFastModeSettings(), + { + onChange: (settings, changedRow) => { + this.settingsManager.setCodexFastModeSettings({ [changedRow]: settings[changedRow] }); + 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(); + }, + }, + ); + return { component: selector, focus: selector }; + }); + } + private showSettingsSelector(): void { this.showSelector((done) => { const selector = new SettingsSelectorComponent( @@ -4781,6 +4868,7 @@ export class InteractiveMode { this.session.setScopedModels([]); } await this.updateAvailableProviderCount(); + this.setupAutocompleteProvider(); this.ui.requestRender(); }; @@ -5250,6 +5338,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 +5404,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..fd9f2cda1 --- /dev/null +++ b/packages/coding-agent/test/codex-fast-mode.test.ts @@ -0,0 +1,218 @@ +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, + isCodexFastModeEnabledForSession, + isCodexFastModeCandidateModelId, + isCodexFastModeSupportedProvider, + shouldApplyCodexFastModeForScope, + streamWithCodexFastMode, + withCodexFastModePayload, + withCodexFastModeStreamOptions, +} from "../src/core/codex-fast-mode.ts"; +import type { OrchestrationContext } from "../src/core/extensions/index.ts"; + +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", + 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([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", () => { + 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"); + 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(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", () => { + 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" }); + expect(withCodexFastModePayload({ service_tier: undefined }, true)).toEqual({ + 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 new file mode 100644 index 000000000..a806413b5 --- /dev/null +++ b/packages/coding-agent/test/fast-mode-selector.test.ts @@ -0,0 +1,88 @@ +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("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", () => { + 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 arrows and toggle keys", () => { + 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 }, "chat"); + + 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: 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", () => { + 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/footer-codex-fast-mode.test.ts b/packages/coding-agent/test/footer-codex-fast-mode.test.ts new file mode 100644 index 000000000..b902f6775 --- /dev/null +++ b/packages/coding-agent/test/footer-codex-fast-mode.test.ts @@ -0,0 +1,96 @@ +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"; +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, ""); +} + +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, + options: { reasoning?: boolean; thinkingLevel?: ThinkingLevel } = {}, +): AgentSession { + return { + state: { + model: { + provider: "openai", + id: "gpt-5.1-codex", + reasoning: options.reasoning ?? false, + }, + thinkingLevel: options.thinkingLevel ?? "off", + }, + settingsManager: { + getCodexFastModeSettings: () => ({ chat, workflow }), + }, + orchestrationContext, + 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"); + }); + + 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); + + 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/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/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index ff1cfd53f..6fff4a226 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,126 @@ 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, + }; + } + + 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: () => + (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 }, + 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"); + }); + + 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", () => { 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..864b38587 --- /dev/null +++ b/packages/coding-agent/test/sdk-codex-fast-mode.test.ts @@ -0,0 +1,318 @@ +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, 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"; +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, + }; +} + +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 = { + 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(() => { + vi.unstubAllGlobals(); + 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("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"; + 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", + 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("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", + 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..614394525 --- /dev/null +++ b/packages/coding-agent/test/settings-manager-codex-fast-mode.test.ts @@ -0,0 +1,143 @@ +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 { ENV_CODEX_FAST_MODE } from "../src/config.ts"; +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 }); + }); + + 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 }); + }); + + 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"), + 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/CHANGELOG.md b/packages/workflows/CHANGELOG.md index d76492017..39a5c9a4e 100644 --- a/packages/workflows/CHANGELOG.md +++ b/packages/workflows/CHANGELOG.md @@ -6,6 +6,12 @@ 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, 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 ### Changed diff --git a/packages/workflows/src/extension/wiring.ts b/packages/workflows/src/extension/wiring.ts index 54e9462cb..89e66a6e3 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,24 @@ 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 }; + const resultSettingsManager = result.session.settingsManager; + const settingsManager = sessionOptions?.settingsManager ?? resultSettingsManager; + return { + session: result.session, + ...(settingsManager?.getCodexFastModeSettings !== undefined + ? { 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 +352,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 +371,7 @@ export function buildRuntimeAdapters( uiContext: makeStageExtensionUiContext(pi.ui ?? {}, meta, broker), }); } - return result.session; + return result; }, }, }; diff --git a/packages/workflows/src/runs/foreground/executor.ts b/packages/workflows/src/runs/foreground/executor.ts index 1af221208..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 { @@ -71,7 +71,7 @@ import { appendStageEnd, appendRunEnd, } from "../../shared/persistence-session-entries.js"; -import { validateWorkflowModels } 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"; @@ -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 } : {}), }), @@ -2188,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, @@ -2196,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; @@ -2352,10 +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.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); @@ -2453,7 +2467,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(); @@ -2481,6 +2495,33 @@ export async function run>( } stageSnapshot.status = "running"; stageSnapshot.startedAt = Date.now(); + const hasExplicitFastModeCandidate = async (): Promise => { + 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({ + primaryModel: options?.model, + fallbackModels: options?.fallbackModels, + catalog: opts.models, + }); + return candidates.some((candidate) => isCodexFastModeCandidateModelId(candidate.id)); + } catch { + return false; + } + }; + const hasNoExplicitModelConfig = options?.model === undefined && options?.fallbackModels === undefined; + const promptAdapterHandlesInitialPrompt = adapters.prompt !== undefined; + if (eagerSession && !promptAdapterHandlesInitialPrompt && (hasNoExplicitModelConfig || await hasExplicitFastModeCandidate())) { + try { + await innerCtx.__ensureSession(); + } catch (err) { + if (!(err instanceof Error && err.message.includes("prompt adapter not configured"))) { + throw err; + } + } + } + applyModelFallbackMeta(innerCtx.__modelFallbackMeta()); activeStore.recordStageStart(runId, stageSnapshot); // Persistence: append stage.start entry @@ -2556,10 +2597,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.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(); @@ -2626,7 +2664,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), @@ -2706,6 +2744,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 e9c58a6ef..bda647d70 100644 --- a/packages/workflows/src/runs/foreground/stage-runner.ts +++ b/packages/workflows/src/runs/foreground/stage-runner.ts @@ -9,7 +9,13 @@ 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 { + shouldApplyCodexFastModeForScope, + SessionManager, + type AgentSession, + type CreateAgentSessionOptions, + type PromptOptions, +} from "@bastani/atomic"; import type { CompleteStageOpts, StageContext, @@ -47,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; @@ -57,12 +65,27 @@ 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 { readonly model?: string; + readonly fastMode?: boolean; readonly attemptedModels?: readonly string[]; readonly modelAttempts?: readonly WorkflowModelAttempt[]; readonly warnings?: readonly string[]; @@ -94,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 { @@ -532,19 +557,52 @@ export function createStageContext(opts: StageRunnerOpts): InternalStageContext return { ...(stageOptions ?? {}), model: candidate.value, fallbackModels: undefined }; } - function attachSession(created: StageSessionRuntime): StageSessionRuntime { - session = created; + let sessionSettingsManager: WorkflowFastModeSettingsManager | undefined; + + function isWorkflowFastModeEnabled(): boolean | undefined { + const model = session?.model; + const settingsManager = sessionSettingsManager ?? stageOptions?.settingsManager; + if (model === undefined || settingsManager === undefined) return undefined; + 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 }; + } + + function attachSession(created: StageSessionRuntime | StageSessionCreateResult): StageSessionRuntime { + const result = normalizeSessionCreateResult(created); + session = result.session; + sessionSettingsManager = result.settingsManager ?? result.session.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( @@ -580,6 +638,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?.(); @@ -651,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 }); @@ -835,14 +895,7 @@ export function createStageContext(opts: StageRunnerOpts): InternalStageContext }, __modelFallbackMeta() { - const attemptedModels = modelAttempts.map((attempt) => attempt.model); - const model = selectedModel ?? workflowModelId(session?.model); - return { - ...(model !== undefined ? { model } : {}), - ...(attemptedModels.length > 0 ? { attemptedModels } : {}), - ...(modelAttempts.length > 0 ? { modelAttempts: [...modelAttempts] } : {}), - ...(modelWarnings.length > 0 ? { warnings: [...modelWarnings] } : {}), - }; + return currentModelFallbackMeta(); }, async __requestPause() { 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/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] diff --git a/test/unit/executor.test.ts b/test/unit/executor.test.ts index a0d458bfe..2c665cb88 100644 --- a/test/unit/executor.test.ts +++ b/test/unit/executor.test.ts @@ -1563,6 +1563,239 @@ 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("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("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") 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-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; diff --git a/test/unit/stage-runner.test.ts b/test/unit/stage-runner.test.ts index f9ab520c2..69bb32ca6 100644 --- a/test/unit/stage-runner.test.ts +++ b/test/unit/stage-runner.test.ts @@ -434,6 +434,97 @@ describe("createStageContext — model fallback", () => { assert.deepEqual(ctx.__modelFallbackMeta().modelAttempts?.map((attempt) => attempt.success), [false, true]); }); + test("workflow fast mode keeps raw model metadata with a structured fast flag", 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"); + 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 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() { + 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 () => { const calls: string[] = []; const agentSession: AgentSessionAdapter = { 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({}, {