feat: add provider-declared features system with Codex fast mode (#186)

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.
This commit is contained in:
Mohamed Boudra
2026-04-04 14:25:20 +07:00
committed by GitHub
parent 99114ddd11
commit 42cfed514c
17 changed files with 1081 additions and 8 deletions

View File

@@ -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<string>;
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<string, typeof Zap> = {
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<string>(),
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 (
<Tooltip
key={`feature-${feature.id}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
disabled={disabled}
onPress={() => 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}`}
>
<FeatureIcon
size={theme.iconSize.md}
color={
feature.value
? theme.colors.palette.yellow[400]
: theme.colors.foregroundMuted
}
/>
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{feature.label}</Text>
</TooltipContent>
</Tooltip>
);
}
if (feature.type === "select") {
const FeatureIcon = getFeatureIcon(feature.icon);
const selectedOption = feature.options.find((o) => o.id === feature.value);
return (
<DropdownMenu
key={`feature-${feature.id}`}
open={openSelector === `feature-${feature.id}`}
onOpenChange={(open) =>
setOpenSelector(open ? `feature-${feature.id}` : null)
}
>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
<DropdownMenuTrigger
disabled={disabled}
style={({ pressed, hovered }) => [
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}`}
>
<FeatureIcon
size={theme.iconSize.md}
color={theme.colors.foregroundMuted}
/>
<Text style={styles.modeBadgeText}>
{selectedOption?.label ?? feature.label}
</Text>
<ChevronDown
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{feature.label}</Text>
</TooltipContent>
</Tooltip>
<DropdownMenuContent side="top" align="start">
{feature.options.map((option) => (
<DropdownMenuItem
key={option.id}
selected={option.id === feature.value}
onSelect={() => onSetFeature?.(feature.id, option.id)}
>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
return null;
})}
{modeOptions && modeOptions.length > 0 ? (
<>
<Tooltip
@@ -551,6 +664,86 @@ function ControlledStatusBar({
</View>
) : null}
{features?.map((feature) => {
if (feature.type === "toggle") {
const FeatureIcon = getFeatureIcon(feature.icon);
return (
<View key={`feature-${feature.id}`} style={styles.sheetSection}>
<Pressable
disabled={disabled}
onPress={() => 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}`}
>
<FeatureIcon
size={theme.iconSize.md}
color={
feature.value
? theme.colors.palette.yellow[400]
: theme.colors.foregroundMuted
}
/>
<Text style={styles.sheetSelectText}>{feature.label}</Text>
<Text style={styles.modeBadgeText}>
{feature.value ? "On" : "Off"}
</Text>
</Pressable>
</View>
);
}
if (feature.type === "select") {
const selectedOption = feature.options.find((o) => o.id === feature.value);
return (
<View key={`feature-${feature.id}`} style={styles.sheetSection}>
<DropdownMenu
open={openSelector === `feature-${feature.id}`}
onOpenChange={(open) =>
setOpenSelector(open ? `feature-${feature.id}` : null)
}
>
<DropdownMenuTrigger
disabled={disabled}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
disabled && styles.disabledSheetSelect,
]}
accessibilityRole="button"
accessibilityLabel={feature.label}
testID={`agent-feature-${feature.id}`}
>
<Text style={styles.sheetSelectText}>
{selectedOption?.label ?? feature.label}
</Text>
<ChevronDown
size={theme.iconSize.md}
color={theme.colors.foregroundMuted}
/>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{feature.options.map((option) => (
<DropdownMenuItem
key={option.id}
selected={option.id === feature.value}
onSelect={() => onSetFeature?.(feature.id, option.id)}
>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
);
}
return null;
})}
{modeOptions && modeOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu
@@ -614,6 +807,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
currentModeId: currentAgent.currentModeId,
runtimeModelId: currentAgent.runtimeInfo?.model ?? null,
model: currentAgent.model,
features: currentAgent.features,
thinkingOptionId: currentAgent.thinkingOptionId,
}
: null;
@@ -782,6 +976,15 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
});
}}
features={agent.features}
onSetFeature={(featureId, value) => {
if (!client) {
return;
}
void client.setAgentFeature(agentId, featureId, value).catch((error) => {
console.warn("[AgentStatusBar] setAgentFeature failed", error);
});
}}
isModelLoading={isProviderModelsQueryLoading(modelsQuery)}
disabled={!client}
/>

View File

@@ -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;

View File

@@ -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,

View File

@@ -1707,6 +1707,35 @@ export class DaemonClient {
}
}
async setAgentFeature(agentId: string, featureId: string, value: unknown): Promise<void> {
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<void> {
const requestId = this.createRequestId();
const message = SessionInboundMessageSchema.parse({

View File

@@ -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<void> {}
}
function createFeature(overrides: Partial<AgentFeature> = {}): 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<AgentSessionConfig> | undefined;
async isAvailable(): Promise<boolean> {
return true;
}
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new TestAgentSession(config);
}
async resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
): Promise<AgentSession> {
this.lastResumeOverrides = overrides;
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
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<AgentSession> {
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<AgentSession> {
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");

View File

@@ -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<string, AgentPermissionRequest>;
pendingReplacement: boolean;
@@ -940,6 +942,19 @@ export class AgentManager {
this.emitState(agent);
}
async setAgentFeature(agentId: string, featureId: string, value: unknown): Promise<void> {
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<void> {
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 },

View File

@@ -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<AgentPermissionRequest> = {}): Agen
return { ...base, ...overrides };
}
function createFeature(overrides: Partial<AgentFeature> = {}): 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);
});
});

View File

@@ -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;

View File

@@ -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<string, unknown>;
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<AgentRunResult>;
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
@@ -408,6 +431,7 @@ export interface AgentSession {
listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
setFeature?(featureId: string, value: unknown): Promise<void>;
}
export interface ListModelsOptions {

View File

@@ -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");

View File

@@ -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<typeof STORED_AGENT_SCHEMA>;

View File

@@ -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> = {}): AgentSessionConfig {
return {
provider: CODEX_PROVIDER,
cwd: "/tmp/codex-fast-mode-test",
modeId: "auto",
model: "gpt-5.4",
...overrides,
};
}
function createSession(configOverrides: Partial<AgentSessionConfig> = {}) {
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),
);
});
});

View File

@@ -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<string, AgentPermissionRequest>();
@@ -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<void> {
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<void> {
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<void> {
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,
};

View File

@@ -43,6 +43,7 @@ export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSe
modeId: record.lastModeId ?? record.config?.modeId ?? undefined,
model: record.config?.model ?? undefined,
thinkingOptionId: record.config?.thinkingOptionId ?? undefined,
featureValues: record.config?.featureValues ?? undefined,
title: record.config?.title ?? undefined,
extra: record.config?.extra ?? undefined,
systemPrompt: record.config?.systemPrompt ?? undefined,
@@ -61,6 +62,7 @@ export function buildSessionConfig(record: StoredAgentRecord): AgentSessionConfi
modeId: overrides.modeId,
model: overrides.model,
thinkingOptionId: overrides.thinkingOptionId,
featureValues: overrides.featureValues,
title: overrides.title,
extra: overrides.extra,
systemPrompt: overrides.systemPrompt,

View File

@@ -1565,6 +1565,15 @@ export class Session {
await this.handleSetAgentModelRequest(msg.agentId, msg.modelId, msg.requestId);
break;
case "set_agent_feature_request":
await this.handleSetAgentFeatureRequest(
msg.agentId,
msg.featureId,
msg.value,
msg.requestId,
);
break;
case "set_agent_thinking_request":
await this.handleSetAgentThinkingRequest(
msg.agentId,
@@ -3429,6 +3438,53 @@ export class Session {
}
}
private async handleSetAgentFeatureRequest(
agentId: string,
featureId: string,
value: unknown,
requestId: string,
): Promise<void> {
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,

View File

@@ -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");
});
});

View File

@@ -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<AgentModelDefinition> = 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<typeof SendAgentMessageResponseMessageSchema>;
export type SetVoiceModeResponseMessage = z.infer<typeof SetVoiceModeResponseMessageSchema>;
export type SetAgentModeResponseMessage = z.infer<typeof SetAgentModeResponseMessageSchema>;
export type SetAgentModelResponseMessage = z.infer<typeof SetAgentModelResponseMessageSchema>;
export type SetAgentThinkingResponseMessage = z.infer<typeof SetAgentThinkingResponseMessageSchema>;
export type SetAgentFeatureResponseMessage = z.infer<typeof SetAgentFeatureResponseMessageSchema>;
export type UpdateAgentResponseMessage = z.infer<typeof UpdateAgentResponseMessageSchema>;
export type WaitForFinishResponseMessage = z.infer<typeof WaitForFinishResponseMessageSchema>;
export type AgentPermissionRequestMessage = z.infer<typeof AgentPermissionRequestMessageSchema>;
@@ -2479,6 +2529,7 @@ export type UpdateAgentRequestMessage = z.infer<typeof UpdateAgentRequestMessage
export type SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessageSchema>;
export type SetAgentModelRequestMessage = z.infer<typeof SetAgentModelRequestMessageSchema>;
export type SetAgentThinkingRequestMessage = z.infer<typeof SetAgentThinkingRequestMessageSchema>;
export type SetAgentFeatureRequestMessage = z.infer<typeof SetAgentFeatureRequestMessageSchema>;
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
export type CheckoutStatusRequest = z.infer<typeof CheckoutStatusRequestSchema>;
export type CheckoutStatusResponse = z.infer<typeof CheckoutStatusResponseSchema>;