mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
143
packages/server/src/shared/agent-feature-schemas.test.ts
Normal file
143
packages/server/src/shared/agent-feature-schemas.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user