From 42cfed514cfc697d1932ca24975badb746469286 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 4 Apr 2026 14:25:20 +0700 Subject: [PATCH] feat: add provider-declared features system with Codex fast mode (#186) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a generic feature system where providers declare dynamic features (toggles/selects) and the app renders controls automatically. One message pair (set_agent_feature_request/response) handles all feature mutations. Feature values persist and restore on agent resume. First consumer: Codex fast mode (service_tier) — gated to supported model families, with proper cleanup on model switch. --- .../app/src/components/agent-status-bar.tsx | 207 +++++++++++++++- packages/app/src/stores/session-store.ts | 2 + packages/app/src/utils/agent-snapshots.ts | 1 + packages/server/src/client/daemon-client.ts | 29 +++ .../src/server/agent/agent-manager.test.ts | 220 ++++++++++++++++++ .../server/src/server/agent/agent-manager.ts | 19 ++ .../server/agent/agent-projections.test.ts | 20 ++ .../src/server/agent/agent-projections.ts | 7 + .../src/server/agent/agent-sdk-types.ts | 24 ++ .../src/server/agent/agent-storage.test.ts | 60 +++++ .../server/src/server/agent/agent-storage.ts | 10 +- .../codex-app-server-agent.features.test.ts | 174 ++++++++++++++ .../agent/providers/codex-app-server-agent.ts | 64 ++++- .../server/src/server/persistence-hooks.ts | 2 + packages/server/src/server/session.ts | 56 +++++ .../src/shared/agent-feature-schemas.test.ts | 143 ++++++++++++ packages/server/src/shared/messages.ts | 51 ++++ 17 files changed, 1081 insertions(+), 8 deletions(-) create mode 100644 packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts create mode 100644 packages/server/src/shared/agent-feature-schemas.test.ts diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index afbd7d866..2d4d99054 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -3,7 +3,7 @@ import { View, Text, Platform, Pressable, Keyboard } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useShallow } from "zustand/shallow"; import { useStoreWithEqualityFn } from "zustand/traditional"; -import { Brain, ChevronDown, ShieldAlert, ShieldCheck, ShieldOff } from "lucide-react-native"; +import { Brain, ChevronDown, Settings2, ShieldAlert, ShieldCheck, ShieldOff, Zap } from "lucide-react-native"; import { getProviderIcon } from "@/components/provider-icons"; import { CombinedModelSelector } from "@/components/combined-model-selector"; import { useQuery } from "@tanstack/react-query"; @@ -24,6 +24,7 @@ import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/com import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import type { + AgentFeature, AgentMode, AgentModelDefinition, AgentProvider, @@ -46,7 +47,7 @@ type StatusOption = { label: string; }; -type StatusSelector = "provider" | "mode" | "model" | "thinking"; +type StatusSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`; const PROVIDER_DEFINITION_MAP = new Map( AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]), @@ -73,6 +74,8 @@ type ControlledAgentStatusBarProps = { canSelectModelProvider?: (providerId: string) => boolean; favoriteKeys?: Set; onToggleFavoriteModel?: (provider: string, modelId: string) => void; + features?: AgentFeature[]; + onSetFeature?: (featureId: string, value: unknown) => void; }; export interface DraftAgentStatusBarProps { @@ -112,6 +115,14 @@ function findOptionLabel( return selected?.label ?? fallback; } +const FEATURE_ICONS: Record = { + zap: Zap, +}; + +function getFeatureIcon(icon?: string) { + return (icon && FEATURE_ICONS[icon]) || Settings2; +} + const MODE_ICONS = { ShieldCheck, ShieldAlert, @@ -162,6 +173,8 @@ function ControlledStatusBar({ canSelectModelProvider, favoriteKeys = new Set(), onToggleFavoriteModel, + features, + onSetFeature, }: ControlledAgentStatusBarProps) { const { theme } = useUnistyles(); const isWeb = Platform.OS === "web"; @@ -407,6 +420,106 @@ function ControlledStatusBar({ ) : null} + {features?.map((feature) => { + if (feature.type === "toggle") { + const FeatureIcon = getFeatureIcon(feature.icon); + return ( + + + onSetFeature?.(feature.id, !feature.value)} + style={({ pressed, hovered }) => [ + styles.modeIconBadge, + hovered && styles.modeBadgeHovered, + pressed && styles.modeBadgePressed, + disabled && styles.disabledBadge, + ]} + accessibilityRole="button" + accessibilityLabel={feature.label} + testID={`agent-feature-${feature.id}`} + > + + + + + {feature.label} + + + ); + } + if (feature.type === "select") { + const FeatureIcon = getFeatureIcon(feature.icon); + const selectedOption = feature.options.find((o) => o.id === feature.value); + return ( + + setOpenSelector(open ? `feature-${feature.id}` : null) + } + > + + + [ + styles.modeBadge, + hovered && styles.modeBadgeHovered, + (pressed || openSelector === `feature-${feature.id}`) && + styles.modeBadgePressed, + disabled && styles.disabledBadge, + ]} + accessibilityRole="button" + accessibilityLabel={feature.label} + testID={`agent-feature-${feature.id}`} + > + + + {selectedOption?.label ?? feature.label} + + + + + + {feature.label} + + + + {feature.options.map((option) => ( + onSetFeature?.(feature.id, option.id)} + > + {option.label} + + ))} + + + ); + } + return null; + })} + {modeOptions && modeOptions.length > 0 ? ( <> ) : null} + {features?.map((feature) => { + if (feature.type === "toggle") { + const FeatureIcon = getFeatureIcon(feature.icon); + return ( + + onSetFeature?.(feature.id, !feature.value)} + style={({ pressed }) => [ + styles.sheetSelect, + pressed && styles.sheetSelectPressed, + disabled && styles.disabledSheetSelect, + ]} + accessibilityRole="button" + accessibilityLabel={feature.label} + testID={`agent-feature-${feature.id}`} + > + + {feature.label} + + {feature.value ? "On" : "Off"} + + + + ); + } + if (feature.type === "select") { + const selectedOption = feature.options.find((o) => o.id === feature.value); + return ( + + + setOpenSelector(open ? `feature-${feature.id}` : null) + } + > + [ + styles.sheetSelect, + pressed && styles.sheetSelectPressed, + disabled && styles.disabledSheetSelect, + ]} + accessibilityRole="button" + accessibilityLabel={feature.label} + testID={`agent-feature-${feature.id}`} + > + + {selectedOption?.label ?? feature.label} + + + + + {feature.options.map((option) => ( + onSetFeature?.(feature.id, option.id)} + > + {option.label} + + ))} + + + + ); + } + return null; + })} + {modeOptions && modeOptions.length > 0 ? ( { + if (!client) { + return; + } + void client.setAgentFeature(agentId, featureId, value).catch((error) => { + console.warn("[AgentStatusBar] setAgentFeature failed", error); + }); + }} isModelLoading={isProviderModelsQueryLoading(modelsQuery)} disabled={!client} /> diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 0646658cb..e80908e99 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -10,6 +10,7 @@ import type { AgentPermissionResponse, AgentPermissionRequest, AgentSessionConfig, + AgentFeature, AgentProvider, AgentMode, AgentCapabilityFlags, @@ -99,6 +100,7 @@ export interface Agent { title: string | null; cwd: string; model: string | null; + features?: AgentFeature[]; thinkingOptionId?: string | null; requiresAttention?: boolean; attentionReason?: "finished" | "error" | "permission" | null; diff --git a/packages/app/src/utils/agent-snapshots.ts b/packages/app/src/utils/agent-snapshots.ts index 223c2437a..9b9f5691f 100644 --- a/packages/app/src/utils/agent-snapshots.ts +++ b/packages/app/src/utils/agent-snapshots.ts @@ -47,6 +47,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: title: snapshot.title ?? null, cwd: snapshot.cwd, model: snapshot.model ?? null, + features: snapshot.features, thinkingOptionId: snapshot.thinkingOptionId ?? null, requiresAttention: snapshot.requiresAttention ?? false, attentionReason: snapshot.attentionReason ?? null, diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 886eaa841..bf49719fa 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -1707,6 +1707,35 @@ export class DaemonClient { } } + async setAgentFeature(agentId: string, featureId: string, value: unknown): Promise { + const requestId = this.createRequestId(); + const message = SessionInboundMessageSchema.parse({ + type: "set_agent_feature_request", + agentId, + featureId, + value, + requestId, + }); + const payload = await this.sendRequest({ + requestId, + message, + timeout: 15000, + options: { skipQueue: true }, + select: (msg) => { + if (msg.type !== "set_agent_feature_response") { + return null; + } + if (msg.payload.requestId !== requestId) { + return null; + } + return msg.payload; + }, + }); + if (!payload.accepted) { + throw new Error(payload.error ?? "setAgentFeature rejected"); + } + } + async setAgentThinkingOption(agentId: string, thinkingOptionId: string | null): Promise { const requestId = this.createRequestId(); const message = SessionInboundMessageSchema.parse({ diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index eceaadd11..ed41c70b9 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -7,8 +7,10 @@ import { randomUUID } from "node:crypto"; import { createTestLogger } from "../../test-utils/test-logger.js"; import { AgentManager } from "./agent-manager.js"; import { AgentStorage } from "./agent-storage.js"; +import { buildConfigOverrides } from "../persistence-hooks.js"; import type { AgentClient, + AgentFeature, AgentLaunchContext, AgentPersistenceHandle, AgentRunResult, @@ -197,6 +199,16 @@ class TestAgentSession implements AgentSession { async close(): Promise {} } +function createFeature(overrides: Partial = {}): AgentFeature { + return { + type: "toggle", + id: "fast_mode", + label: "Fast mode", + value: false, + ...overrides, + }; +} + describe("AgentManager", () => { const logger = createTestLogger(); @@ -481,6 +493,101 @@ describe("AgentManager", () => { }); }); + test("resumeAgentFromPersistence passes featureValues through to the resumed session config", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-resume-features-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class ResumeFeatureCaptureClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + lastResumeOverrides: Partial | undefined; + + async isAvailable(): Promise { + return true; + } + + async createSession(config: AgentSessionConfig): Promise { + return new TestAgentSession(config); + } + + async resumeSession( + handle: AgentPersistenceHandle, + overrides?: Partial, + ): Promise { + this.lastResumeOverrides = overrides; + const metadata = (handle.metadata ?? {}) as Partial; + return new TestAgentSession({ + ...metadata, + ...overrides, + provider: "codex", + cwd: overrides?.cwd ?? metadata.cwd ?? process.cwd(), + }); + } + } + + const now = new Date().toISOString(); + await storage.upsert({ + id: "00000000-0000-4000-8000-000000000138", + provider: "codex", + cwd: workdir, + createdAt: now, + updatedAt: now, + lastActivityAt: now, + title: null, + labels: {}, + lastStatus: "idle", + lastModeId: "plan", + config: { + model: "gpt-5.1", + modeId: "plan", + featureValues: { + fast_mode: true, + }, + }, + persistence: { + provider: "codex", + sessionId: "resume-feature-session", + metadata: { + provider: "codex", + cwd: workdir, + }, + }, + }); + + const record = await storage.get("00000000-0000-4000-8000-000000000138"); + expect(record).not.toBeNull(); + + const client = new ResumeFeatureCaptureClient(); + const manager = new AgentManager({ + clients: { + codex: client, + }, + registry: storage, + logger, + }); + + const resumed = await manager.resumeAgentFromPersistence( + { + provider: "codex", + sessionId: "resume-feature-session", + metadata: { + provider: "codex", + cwd: workdir, + }, + }, + buildConfigOverrides(record!), + record!.id, + ); + + expect(client.lastResumeOverrides?.featureValues).toEqual({ + fast_mode: true, + }); + expect(resumed.config.featureValues).toEqual({ + fast_mode: true, + }); + }); + test("reloadAgentSession preserves timeline and does not force history replay", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-")); const storagePath = join(workdir, "agents"); @@ -629,6 +736,119 @@ describe("AgentManager", () => { expect(live!.updatedAt.getTime()).toBeGreaterThan(Date.parse(before!.updatedAt)); }); + test("setAgentFeature calls session.setFeature and persists featureValues in config", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-set-feature-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class FeatureSession extends TestAgentSession { + readonly features: AgentFeature[] = [createFeature()]; + readonly setFeature = vi.fn(async (featureId: string, value: unknown) => { + const feature = this.features.find((item) => item.id === featureId); + if (feature?.type === "toggle") { + feature.value = Boolean(value); + } + }); + } + + class FeatureClient extends TestAgentClient { + session: FeatureSession | null = null; + + override async createSession(config: AgentSessionConfig): Promise { + this.session = new FeatureSession(config); + return this.session; + } + } + + const client = new FeatureClient(); + const manager = new AgentManager({ + clients: { codex: client }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000128", + }); + + const agent = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + await manager.setAgentFeature(agent.id, "fast_mode", true); + + expect(client.session?.setFeature).toHaveBeenCalledWith("fast_mode", true); + expect(manager.getAgent(agent.id)?.config.featureValues).toEqual({ fast_mode: true }); + }); + + test("setAgentFeature throws when session does not support setFeature", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-set-feature-unsupported-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { codex: new TestAgentClient() }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000129", + }); + + const agent = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + await expect(manager.setAgentFeature(agent.id, "fast_mode", true)).rejects.toThrow( + "Agent session does not support setting features", + ); + }); + + test("emitState syncs features from session to agent", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-emit-state-features-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class FeatureSession extends TestAgentSession { + readonly features: AgentFeature[] = [createFeature()]; + } + + class FeatureClient extends TestAgentClient { + session: FeatureSession | null = null; + + override async createSession(config: AgentSessionConfig): Promise { + this.session = new FeatureSession(config); + return this.session; + } + } + + const client = new FeatureClient(); + const manager = new AgentManager({ + clients: { codex: client }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000130", + }); + + const events: AgentFeature[][] = []; + const agent = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + manager.subscribe((event) => { + if (event.type !== "agent_state" || event.agent.id !== agent.id) { + return; + } + events.push(event.agent.features ?? []); + }); + + if (client.session?.features[0]?.type === "toggle") { + client.session.features[0].value = true; + } + + manager.notifyAgentState(agent.id); + + expect(manager.getAgent(agent.id)?.features).toEqual([createFeature({ value: true })]); + expect(events.at(-1)).toEqual([createFeature({ value: true })]); + }); + test("reloadAgentSession cancels active run and resumes existing session once thread_started is observed", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-active-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index c3a705202..48bb0b310 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -11,6 +11,7 @@ import { z } from "zod"; import type { AgentCapabilityFlags, AgentClient, + AgentFeature, AgentLaunchContext, AgentSlashCommand, AgentMode, @@ -168,6 +169,7 @@ type ManagedAgentBase = { createdAt: Date; updatedAt: Date; availableModes: AgentMode[]; + features?: AgentFeature[]; currentModeId: string | null; pendingPermissions: Map; pendingReplacement: boolean; @@ -940,6 +942,19 @@ export class AgentManager { this.emitState(agent); } + async setAgentFeature(agentId: string, featureId: string, value: unknown): Promise { + const agent = this.requireAgent(agentId); + + if (!agent.session.setFeature) { + throw new Error("Agent session does not support setting features"); + } + + await agent.session.setFeature(featureId, value); + agent.config.featureValues = { ...agent.config.featureValues, [featureId]: value }; + this.touchUpdatedAt(agent); + this.emitState(agent); + } + async setTitle(agentId: string, title: string): Promise { const agent = this.requireAgent(agentId); const normalizedTitle = title.trim(); @@ -2325,6 +2340,10 @@ export class AgentManager { // Keep attention as an edge-triggered unread signal, not a level signal. this.checkAndSetAttention(agent); + if (agent.session?.features) { + agent.features = agent.session.features; + } + this.dispatch({ type: "agent_state", agent: { ...agent }, diff --git a/packages/server/src/server/agent/agent-projections.test.ts b/packages/server/src/server/agent/agent-projections.test.ts index d8e81201d..1eefc4784 100644 --- a/packages/server/src/server/agent/agent-projections.test.ts +++ b/packages/server/src/server/agent/agent-projections.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { AGENT_LIFECYCLE_STATUSES, type AgentLifecycleStatus } from "./agent-manager.js"; import { toAgentPayload, toStoredAgentRecord, type ManagedAgent } from "./agent-projections.js"; import type { + AgentFeature, AgentPermissionRequest, AgentPersistenceHandle, AgentSessionConfig, @@ -111,6 +112,16 @@ function createPermission(overrides: Partial = {}): Agen return { ...base, ...overrides }; } +function createFeature(overrides: Partial = {}): AgentFeature { + return { + type: "toggle", + id: "fast_mode", + label: "Fast mode", + value: true, + ...overrides, + }; +} + describe("toStoredAgentRecord", () => { it("captures lifecycle metadata, config, and persistence", () => { const agent = createManagedAgent({ @@ -291,4 +302,13 @@ describe("toAgentPayload", () => { const payload = toAgentPayload(agent); expect(payload).not.toHaveProperty("lastUsage"); }); + + it("includes features in the snapshot payload", () => { + const features = [createFeature()]; + const agent = createManagedAgent({ features }); + + const payload = toAgentPayload(agent); + + expect(payload.features).toEqual(features); + }); }); diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index 5d449e114..a3f040d04 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -97,6 +97,7 @@ export function toAgentPayload( capabilities: cloneCapabilities(agent.capabilities), currentModeId: agent.currentModeId, availableModes: cloneAvailableModes(agent.availableModes), + features: agent.features, pendingPermissions: sanitizePendingPermissions(agent.pendingPermissions), persistence: sanitizePersistenceHandle(agent.persistence), title: options?.title ?? null, @@ -139,6 +140,12 @@ function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentC if (config.thinkingOptionId) { serializable.thinkingOptionId = config.thinkingOptionId; } + if (Object.prototype.hasOwnProperty.call(config, "featureValues")) { + const featureValues = sanitizeMetadata(config.featureValues); + if (featureValues !== undefined) { + serializable.featureValues = featureValues; + } + } const extra = sanitizeMetadata(config.extra); if (extra !== undefined) { serializable.extra = extra; diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 8fad606fa..9edfda6a2 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -64,6 +64,27 @@ export type AgentSelectOption = { metadata?: AgentMetadata; }; +export type AgentFeatureToggle = { + type: "toggle"; + id: string; + label: string; + description?: string; + icon?: string; + value: boolean; +}; + +export type AgentFeatureSelect = { + type: "select"; + id: string; + label: string; + description?: string; + icon?: string; + value: string | null; + options: AgentSelectOption[]; +}; + +export type AgentFeature = AgentFeatureToggle | AgentFeatureSelect; + export type AgentCapabilityFlags = { supportsStreaming: boolean; supportsSessionPersistence: boolean; @@ -367,6 +388,7 @@ export type AgentSessionConfig = { modeId?: string; model?: string; thinkingOptionId?: string; + featureValues?: Record; title?: string | null; approvalPolicy?: string; sandboxMode?: string; @@ -392,6 +414,7 @@ export interface AgentSession { readonly provider: AgentProvider; readonly id: string | null; readonly capabilities: AgentCapabilityFlags; + readonly features?: AgentFeature[]; run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise; startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>; subscribe(callback: (event: AgentStreamEvent) => void): () => void; @@ -408,6 +431,7 @@ export interface AgentSession { listCommands?(): Promise; setModel?(modelId: string | null): Promise; setThinkingOption?(thinkingOptionId: string | null): Promise; + setFeature?(featureId: string, value: unknown): Promise; } export interface ListModelsOptions { diff --git a/packages/server/src/server/agent/agent-storage.test.ts b/packages/server/src/server/agent/agent-storage.test.ts index 00db5a635..e5aeb9178 100644 --- a/packages/server/src/server/agent/agent-storage.test.ts +++ b/packages/server/src/server/agent/agent-storage.test.ts @@ -6,6 +6,7 @@ import { promises as fs } from "node:fs"; import { createTestLogger } from "../../test-utils/test-logger.js"; import { AgentStorage } from "./agent-storage.js"; +import { buildConfigOverrides, buildSessionConfig } from "../persistence-hooks.js"; import type { ManagedAgent } from "./agent-manager.js"; import type { AgentPermissionRequest, @@ -41,6 +42,9 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent systemPrompt: configOverrides.systemPrompt, mcpServers: configOverrides.mcpServers, }; + if (Object.prototype.hasOwnProperty.call(configOverrides, "featureValues")) { + config.featureValues = configOverrides.featureValues; + } const session = lifecycle === "closed" ? null : (overrides.session ?? ({} as AgentSession)); const activeForegroundTurnId = overrides.activeForegroundTurnId ?? (lifecycle === "running" ? "test-turn-id" : null); @@ -150,6 +154,62 @@ describe("AgentStorage", () => { expect(persisted.config?.extra?.claude).toMatchObject({ maxThinkingTokens: 1024 }); }); + test("applySnapshot stores and reloads featureValues when present", async () => { + await storage.applySnapshot( + createManagedAgent({ + id: "agent-feature-values", + config: { + featureValues: { + fast_mode: true, + }, + }, + }), + ); + + const record = await storage.get("agent-feature-values"); + expect(record?.config?.featureValues).toEqual({ fast_mode: true }); + + const reloaded = new AgentStorage(storagePath, logger); + const persisted = await reloaded.get("agent-feature-values"); + expect(persisted?.config?.featureValues).toEqual({ fast_mode: true }); + expect(buildSessionConfig(persisted!).featureValues).toEqual({ fast_mode: true }); + }); + + test("applySnapshot keeps featureValues absent when they were never set", async () => { + await storage.applySnapshot( + createManagedAgent({ + id: "agent-no-feature-values", + }), + ); + + const reloaded = new AgentStorage(storagePath, logger); + const persisted = await reloaded.get("agent-no-feature-values"); + expect(persisted?.config?.featureValues).toBeUndefined(); + expect(buildSessionConfig(persisted!).featureValues).toBeUndefined(); + }); + + test("buildConfigOverrides includes featureValues when present in stored config", async () => { + await storage.applySnapshot( + createManagedAgent({ + id: "agent-resume-overrides", + config: { + featureValues: { + fast_mode: true, + }, + }, + }), + ); + + const record = await storage.get("agent-resume-overrides"); + expect(record).not.toBeNull(); + expect(buildConfigOverrides(record!)).toMatchObject({ + cwd: "/tmp/project", + featureValues: { + fast_mode: true, + }, + }); + }); + test("applySnapshot preserves original createdAt timestamp", async () => { const agentId = "agent-created-at"; const firstTimestamp = new Date("2025-01-01T00:00:00.000Z"); diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index 6c0ebd54d..e7fce3d0c 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -15,6 +15,7 @@ const SERIALIZABLE_CONFIG_SCHEMA = z modeId: z.string().nullable().optional(), model: z.string().nullable().optional(), thinkingOptionId: z.string().nullable().optional(), + featureValues: z.record(z.unknown()).nullable().optional(), extra: z.record(z.any()).nullable().optional(), systemPrompt: z.string().nullable().optional(), mcpServers: z.record(z.any()).nullable().optional(), @@ -65,7 +66,14 @@ const STORED_AGENT_SCHEMA = z.object({ export type SerializableAgentConfig = Pick< AgentSessionConfig, - "title" | "modeId" | "model" | "thinkingOptionId" | "extra" | "systemPrompt" | "mcpServers" + | "title" + | "modeId" + | "model" + | "thinkingOptionId" + | "featureValues" + | "extra" + | "systemPrompt" + | "mcpServers" >; export type StoredAgentRecord = z.infer; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts new file mode 100644 index 000000000..d7870895f --- /dev/null +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, test, vi } from "vitest"; + +import type { AgentSession, AgentSessionConfig } from "../agent-sdk-types.js"; +import { __codexAppServerInternals } from "./codex-app-server-agent.js"; +import { createTestLogger } from "../../../test-utils/test-logger.js"; + +const CODEX_PROVIDER = "codex"; + +function createConfig(overrides: Partial = {}): AgentSessionConfig { + return { + provider: CODEX_PROVIDER, + cwd: "/tmp/codex-fast-mode-test", + modeId: "auto", + model: "gpt-5.4", + ...overrides, + }; +} + +function createSession(configOverrides: Partial = {}) { + const config = createConfig(configOverrides); + const session = new __codexAppServerInternals.CodexAppServerAgentSession( + { ...config, provider: CODEX_PROVIDER }, + null, + createTestLogger(), + () => { + throw new Error("Test session cannot spawn Codex app-server"); + }, + ) as unknown as AgentSession & { [key: string]: unknown }; + session.connected = true; + session.currentThreadId = "test-thread"; + return session; +} + +describe("Codex app-server provider fast mode", () => { + test("features returns fast_mode toggle when model supports it", async () => { + const session = createSession(); + + expect(session.features).toEqual([ + { + type: "toggle", + id: "fast_mode", + label: "Fast", + description: "Priority inference at 2x usage", + icon: "zap", + value: false, + }, + ]); + + await session.setFeature?.("fast_mode", true); + + expect(session.features).toEqual([ + { + type: "toggle", + id: "fast_mode", + label: "Fast", + description: "Priority inference at 2x usage", + icon: "zap", + value: true, + }, + ]); + }); + + test("features returns empty array when model does not support fast mode", () => { + const session = createSession({ model: "gpt-3.5-turbo" }); + + expect(session.features).toEqual([]); + }); + + test("setFeature('fast_mode', true) sets serviceTier to fast", async () => { + const session = createSession(); + + await session.setFeature?.("fast_mode", true); + + expect((session as any).serviceTier).toBe("fast"); + }); + + test("setFeature('fast_mode', false) clears serviceTier to null", async () => { + const session = createSession({ + featureValues: { fast_mode: true }, + }); + + await session.setFeature?.("fast_mode", false); + + expect((session as any).serviceTier).toBeNull(); + }); + + test("setFeature invalidates cachedRuntimeInfo", async () => { + const session = createSession(); + + await session.getRuntimeInfo(); + expect((session as any).cachedRuntimeInfo).not.toBeNull(); + + await session.setFeature?.("fast_mode", true); + + expect((session as any).cachedRuntimeInfo).toBeNull(); + }); + + test("setFeature throws for unknown feature ids", async () => { + const session = createSession(); + + await expect(session.setFeature?.("unknown_feature", true)).rejects.toThrow( + "Unknown Codex feature: unknown_feature", + ); + }); + + test("constructor restores serviceTier from config.featureValues", () => { + const session = createSession({ + featureValues: { fast_mode: true }, + }); + + expect((session as any).serviceTier).toBe("fast"); + expect(session.features).toEqual([ + { + type: "toggle", + id: "fast_mode", + label: "Fast", + description: "Priority inference at 2x usage", + icon: "zap", + value: true, + }, + ]); + }); + + test("startTurn includes serviceTier when fast mode is enabled", async () => { + const session = createSession(); + const request = vi.fn().mockResolvedValue(undefined); + (session as any).client = { request }; + (session as any).connected = true; + (session as any).currentThreadId = "thread-123"; + (session as any).ensureThreadLoaded = vi.fn().mockResolvedValue(undefined); + (session as any).ensureThread = vi.fn().mockResolvedValue(undefined); + (session as any).buildUserInput = vi.fn().mockResolvedValue([{ type: "text", text: "hi" }]); + (session as any).resolveSlashCommandInvocation = vi.fn().mockResolvedValue(null); + + await session.setFeature?.("fast_mode", true); + await session.startTurn("hello"); + + expect(request).toHaveBeenCalledWith( + "turn/start", + expect.objectContaining({ + serviceTier: "fast", + }), + expect.any(Number), + ); + }); + + test("setModel clears fast mode when switching to an unsupported model", async () => { + const session = createSession(); + const request = vi.fn().mockResolvedValue(undefined); + (session as any).client = { request }; + (session as any).connected = true; + (session as any).currentThreadId = "thread-123"; + (session as any).ensureThreadLoaded = vi.fn().mockResolvedValue(undefined); + (session as any).ensureThread = vi.fn().mockResolvedValue(undefined); + (session as any).buildUserInput = vi.fn().mockResolvedValue([{ type: "text", text: "hi" }]); + (session as any).resolveSlashCommandInvocation = vi.fn().mockResolvedValue(null); + + await session.setFeature?.("fast_mode", true); + await session.setModel("gpt-3.5-turbo"); + + expect(session.features).toEqual([]); + expect((session as any).serviceTier).toBeNull(); + + await session.startTurn("hello"); + + expect(request).toHaveBeenCalledWith( + "turn/start", + expect.not.objectContaining({ + serviceTier: expect.anything(), + }), + expect.any(Number), + ); + }); +}); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index bd9bbdff4..f9e1d23fb 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -1,6 +1,8 @@ import type { AgentCapabilityFlags, AgentClient, + AgentFeature, + AgentFeatureToggle, AgentLaunchContext, AgentMode, AgentModelDefinition, @@ -85,6 +87,20 @@ const CODEX_MODES: AgentMode[] = [ ]; const DEFAULT_CODEX_MODE_ID = "auto"; +const CODEX_FAST_MODE_SUPPORTED_MODE_PREFIXES = [ + "gpt-5", + "gpt-4.1", + "o3", + "o4-mini", +] as const; +const CODEX_FAST_MODE_FEATURE: AgentFeatureToggle = { + type: "toggle", + id: "fast_mode", + label: "Fast", + description: "Priority inference at 2x usage", + icon: "zap", + value: false, +}; const MODE_PRESETS: Record< string, @@ -140,6 +156,16 @@ function normalizeCodexModelLabel(displayName: string): string { return displayName.replace(/\bgpt\b/gi, "GPT"); } +function codexModelSupportsFastMode(modelId: string | null | undefined): boolean { + const normalizedModelId = normalizeCodexModelId(modelId); + if (!normalizedModelId) { + return false; + } + return CODEX_FAST_MODE_SUPPORTED_MODE_PREFIXES.some( + (prefix) => normalizedModelId === prefix || normalizedModelId.startsWith(prefix), + ); +} + type CodexConfiguredDefaults = { model?: string; thinkingOptionId?: string; @@ -2019,11 +2045,6 @@ function buildCodexAppServerEnv( }; } -export const __codexAppServerInternals = { - buildCodexAppServerEnv, - mapCodexPatchNotificationToToolCall, -}; - class CodexAppServerAgentSession implements AgentSession { readonly provider = CODEX_PROVIDER; readonly capabilities = CODEX_APP_SERVER_CAPABILITIES; @@ -2038,6 +2059,7 @@ class CodexAppServerAgentSession implements AgentSession { private nextTurnOrdinal = 0; private activeForegroundTurnId: string | null = null; private cachedRuntimeInfo: AgentRuntimeInfo | null = null; + private serviceTier: "fast" | null = null; private historyPending = false; private persistedHistory: AgentTimelineItem[] = []; private pendingPermissions = new Map(); @@ -2094,6 +2116,9 @@ class CodexAppServerAgentSession implements AgentSession { this.currentMode = config.modeId; this.config = config; this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId); + if (this.config.featureValues?.fast_mode) { + this.serviceTier = "fast"; + } if (this.resumeHandle?.sessionId) { this.currentThreadId = this.resumeHandle.sessionId; @@ -2105,6 +2130,13 @@ class CodexAppServerAgentSession implements AgentSession { return this.currentThreadId; } + get features(): AgentFeature[] { + if (!codexModelSupportsFastMode(this.config.model)) { + return []; + } + return [{ ...CODEX_FAST_MODE_FEATURE, value: this.serviceTier === "fast" }]; + } + async connect(): Promise { if (this.connected) return; const child = this.spawnAppServer(); @@ -2495,6 +2527,9 @@ class CodexAppServerAgentSession implements AgentSession { if (thinkingOptionId) { params.effort = thinkingOptionId; } + if (this.serviceTier) { + params.serviceTier = this.serviceTier; + } if (this.resolvedCollaborationMode) { params.collaborationMode = { mode: this.resolvedCollaborationMode.mode, @@ -2586,6 +2621,9 @@ class CodexAppServerAgentSession implements AgentSession { async setModel(modelId: string | null): Promise { this.config.model = modelId ?? undefined; + if (!codexModelSupportsFastMode(this.config.model)) { + this.serviceTier = null; + } this.resolvedCollaborationMode = this.resolveCollaborationMode(this.currentMode); this.cachedRuntimeInfo = null; } @@ -2596,6 +2634,15 @@ class CodexAppServerAgentSession implements AgentSession { this.cachedRuntimeInfo = null; } + async setFeature(featureId: string, value: unknown): Promise { + if (featureId === "fast_mode") { + this.serviceTier = value ? "fast" : null; + this.cachedRuntimeInfo = null; + return; + } + throw new Error(`Unknown Codex feature: ${featureId}`); + } + getPendingPermissions(): AgentPermissionRequest[] { return Array.from(this.pendingPermissions.values()); } @@ -3636,3 +3683,10 @@ export class CodexAppServerAgentClient implements AgentClient { return true; } } + +export const __codexAppServerInternals = { + buildCodexAppServerEnv, + codexModelSupportsFastMode, + CodexAppServerAgentSession, + mapCodexPatchNotificationToToolCall, +}; diff --git a/packages/server/src/server/persistence-hooks.ts b/packages/server/src/server/persistence-hooks.ts index 387786a8f..c197a1c2d 100644 --- a/packages/server/src/server/persistence-hooks.ts +++ b/packages/server/src/server/persistence-hooks.ts @@ -43,6 +43,7 @@ export function buildConfigOverrides(record: StoredAgentRecord): Partial { + this.sessionLogger.info( + { agentId, featureId, value, requestId }, + "session: set_agent_feature_request", + ); + + try { + await this.agentManager.setAgentFeature(agentId, featureId, value); + this.sessionLogger.info( + { agentId, featureId, value, requestId }, + "session: set_agent_feature_request success", + ); + this.emit({ + type: "set_agent_feature_response", + payload: { requestId, agentId, accepted: true, error: null }, + }); + } catch (error: any) { + this.sessionLogger.error( + { err: error, agentId, featureId, value, requestId }, + "session: set_agent_feature_request error", + ); + this.emit({ + type: "activity_log", + payload: { + id: uuidv4(), + timestamp: new Date(), + type: "error", + content: `Failed to set agent feature: ${error.message}`, + }, + }); + this.emit({ + type: "set_agent_feature_response", + payload: { + requestId, + agentId, + accepted: false, + error: error?.message ? String(error.message) : "Failed to set agent feature", + }, + }); + } + } + private async handleSetAgentThinkingRequest( agentId: string, thinkingOptionId: string | null, diff --git a/packages/server/src/shared/agent-feature-schemas.test.ts b/packages/server/src/shared/agent-feature-schemas.test.ts new file mode 100644 index 000000000..f761bb4c1 --- /dev/null +++ b/packages/server/src/shared/agent-feature-schemas.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; + +import { + AgentFeatureSchema, + AgentSnapshotPayloadSchema, + SetAgentFeatureRequestMessageSchema, + SetAgentFeatureResponseMessageSchema, +} from "./messages.js"; + +describe("agent feature schemas", () => { + it("parses valid toggle features", () => { + const parsed = AgentFeatureSchema.parse({ + type: "toggle", + id: "fast_mode", + label: "Fast mode", + description: "Uses lower latency service tier", + icon: "bolt", + value: true, + }); + + expect(parsed.type).toBe("toggle"); + if (parsed.type !== "toggle") { + throw new Error("Expected toggle feature"); + } + expect(parsed.value).toBe(true); + }); + + it("parses valid select features", () => { + const parsed = AgentFeatureSchema.parse({ + type: "select", + id: "service_tier", + label: "Service tier", + description: "Choose a processing tier", + icon: "gauge", + value: "flex", + options: [ + { id: "default", label: "Default", isDefault: true }, + { id: "flex", label: "Flex" }, + ], + }); + + expect(parsed.type).toBe("select"); + if (parsed.type !== "select") { + throw new Error("Expected select feature"); + } + expect(parsed.options).toHaveLength(2); + expect(parsed.value).toBe("flex"); + }); + + it("rejects invalid features", () => { + const invalidDiscriminator = AgentFeatureSchema.safeParse({ + type: "slider", + id: "fast_mode", + label: "Fast mode", + value: true, + }); + + const missingToggleValue = AgentFeatureSchema.safeParse({ + type: "toggle", + id: "fast_mode", + label: "Fast mode", + }); + + const missingSelectOptions = AgentFeatureSchema.safeParse({ + type: "select", + id: "service_tier", + label: "Service tier", + value: null, + }); + + expect(invalidDiscriminator.success).toBe(false); + expect(missingToggleValue.success).toBe(false); + expect(missingSelectOptions.success).toBe(false); + }); + + it("parses valid requests", () => { + const parsed = SetAgentFeatureRequestMessageSchema.parse({ + type: "set_agent_feature_request", + agentId: "agent-123", + featureId: "fast_mode", + value: true, + requestId: "req-123", + }); + + expect(parsed.featureId).toBe("fast_mode"); + expect(parsed.value).toBe(true); + }); + + it("parses valid responses", () => { + const parsed = SetAgentFeatureResponseMessageSchema.parse({ + type: "set_agent_feature_response", + payload: { + requestId: "req-123", + agentId: "agent-123", + accepted: true, + error: null, + }, + }); + + expect(parsed.payload.accepted).toBe(true); + expect(parsed.payload.error).toBeNull(); + }); + + it("accepts features on agent snapshot payloads", () => { + const parsed = AgentSnapshotPayloadSchema.parse({ + id: "agent-123", + provider: "codex", + cwd: "/tmp/project", + model: "gpt-5", + features: [ + { + type: "toggle", + id: "fast_mode", + label: "Fast mode", + value: false, + }, + ], + thinkingOptionId: null, + effectiveThinkingOptionId: null, + createdAt: "2026-04-03T12:00:00.000Z", + updatedAt: "2026-04-03T12:00:00.000Z", + lastUserMessageAt: null, + status: "idle", + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: null, + labels: {}, + }); + + expect(parsed.features).toHaveLength(1); + expect(parsed.features?.[0]?.id).toBe("fast_mode"); + }); +}); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 5b9771058..58416e1c3 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -77,6 +77,30 @@ const AgentSelectOptionSchema = z.object({ metadata: z.record(z.unknown()).optional(), }); +export const AgentFeatureToggleSchema = z.object({ + type: z.literal("toggle"), + id: z.string(), + label: z.string(), + description: z.string().optional(), + icon: z.string().optional(), + value: z.boolean(), +}); + +export const AgentFeatureSelectSchema = z.object({ + type: z.literal("select"), + id: z.string(), + label: z.string(), + description: z.string().optional(), + icon: z.string().optional(), + value: z.string().nullable(), + options: z.array(AgentSelectOptionSchema), +}); + +export const AgentFeatureSchema = z.discriminatedUnion("type", [ + AgentFeatureToggleSchema, + AgentFeatureSelectSchema, +]); + const AgentModelDefinitionSchema: z.ZodType = z.object({ provider: AgentProviderSchema, id: z.string(), @@ -135,6 +159,7 @@ const AgentSessionConfigSchema = z.object({ modeId: z.string().optional(), model: z.string().optional(), thinkingOptionId: z.string().optional(), + featureValues: z.record(z.unknown()).optional(), title: z.string().trim().min(1).max(MAX_EXPLICIT_AGENT_TITLE_CHARS).optional().nullable(), approvalPolicy: z.string().optional(), sandboxMode: z.string().optional(), @@ -458,6 +483,7 @@ export const AgentSnapshotPayloadSchema = z.object({ provider: AgentProviderSchema, cwd: z.string(), model: z.string().nullable(), + features: z.array(AgentFeatureSchema).optional(), thinkingOptionId: z.string().nullable().optional(), effectiveThinkingOptionId: z.string().nullable().optional(), createdAt: z.string(), @@ -837,6 +863,24 @@ export const SetAgentThinkingResponseMessageSchema = z.object({ }), }); +export const SetAgentFeatureRequestMessageSchema = z.object({ + type: z.literal("set_agent_feature_request"), + agentId: z.string(), + featureId: z.string(), + value: z.unknown(), + requestId: z.string(), +}); + +export const SetAgentFeatureResponseMessageSchema = z.object({ + type: z.literal("set_agent_feature_response"), + payload: z.object({ + requestId: z.string(), + agentId: z.string(), + accepted: z.boolean(), + error: z.string().nullable(), + }), +}); + export const UpdateAgentResponseMessageSchema = z.object({ type: z.literal("update_agent_response"), payload: z.object({ @@ -1226,6 +1270,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ SetAgentModeRequestMessageSchema, SetAgentModelRequestMessageSchema, SetAgentThinkingRequestMessageSchema, + SetAgentFeatureRequestMessageSchema, AgentPermissionResponseMessageSchema, CheckoutStatusRequestSchema, SubscribeCheckoutDiffRequestSchema, @@ -2310,6 +2355,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ SetAgentModeResponseMessageSchema, SetAgentModelResponseMessageSchema, SetAgentThinkingResponseMessageSchema, + SetAgentFeatureResponseMessageSchema, UpdateAgentResponseMessageSchema, WaitForFinishResponseMessageSchema, AgentPermissionRequestMessageSchema, @@ -2398,6 +2444,10 @@ export type FetchAgentTimelineResponseMessage = z.infer< >; export type SendAgentMessageResponseMessage = z.infer; export type SetVoiceModeResponseMessage = z.infer; +export type SetAgentModeResponseMessage = z.infer; +export type SetAgentModelResponseMessage = z.infer; +export type SetAgentThinkingResponseMessage = z.infer; +export type SetAgentFeatureResponseMessage = z.infer; export type UpdateAgentResponseMessage = z.infer; export type WaitForFinishResponseMessage = z.infer; export type AgentPermissionRequestMessage = z.infer; @@ -2479,6 +2529,7 @@ export type UpdateAgentRequestMessage = z.infer; export type SetAgentModelRequestMessage = z.infer; export type SetAgentThinkingRequestMessage = z.infer; +export type SetAgentFeatureRequestMessage = z.infer; export type AgentPermissionResponseMessage = z.infer; export type CheckoutStatusRequest = z.infer; export type CheckoutStatusResponse = z.infer;