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 { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useShallow } from "zustand/shallow";
|
import { useShallow } from "zustand/shallow";
|
||||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
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 { getProviderIcon } from "@/components/provider-icons";
|
||||||
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import type {
|
import type {
|
||||||
|
AgentFeature,
|
||||||
AgentMode,
|
AgentMode,
|
||||||
AgentModelDefinition,
|
AgentModelDefinition,
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
@@ -46,7 +47,7 @@ type StatusOption = {
|
|||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StatusSelector = "provider" | "mode" | "model" | "thinking";
|
type StatusSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`;
|
||||||
|
|
||||||
const PROVIDER_DEFINITION_MAP = new Map(
|
const PROVIDER_DEFINITION_MAP = new Map(
|
||||||
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
|
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
|
||||||
@@ -73,6 +74,8 @@ type ControlledAgentStatusBarProps = {
|
|||||||
canSelectModelProvider?: (providerId: string) => boolean;
|
canSelectModelProvider?: (providerId: string) => boolean;
|
||||||
favoriteKeys?: Set<string>;
|
favoriteKeys?: Set<string>;
|
||||||
onToggleFavoriteModel?: (provider: string, modelId: string) => void;
|
onToggleFavoriteModel?: (provider: string, modelId: string) => void;
|
||||||
|
features?: AgentFeature[];
|
||||||
|
onSetFeature?: (featureId: string, value: unknown) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface DraftAgentStatusBarProps {
|
export interface DraftAgentStatusBarProps {
|
||||||
@@ -112,6 +115,14 @@ function findOptionLabel(
|
|||||||
return selected?.label ?? fallback;
|
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 = {
|
const MODE_ICONS = {
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
@@ -162,6 +173,8 @@ function ControlledStatusBar({
|
|||||||
canSelectModelProvider,
|
canSelectModelProvider,
|
||||||
favoriteKeys = new Set<string>(),
|
favoriteKeys = new Set<string>(),
|
||||||
onToggleFavoriteModel,
|
onToggleFavoriteModel,
|
||||||
|
features,
|
||||||
|
onSetFeature,
|
||||||
}: ControlledAgentStatusBarProps) {
|
}: ControlledAgentStatusBarProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isWeb = Platform.OS === "web";
|
const isWeb = Platform.OS === "web";
|
||||||
@@ -407,6 +420,106 @@ function ControlledStatusBar({
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : 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 ? (
|
{modeOptions && modeOptions.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
@@ -551,6 +664,86 @@ function ControlledStatusBar({
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : 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 ? (
|
{modeOptions && modeOptions.length > 0 ? (
|
||||||
<View style={styles.sheetSection}>
|
<View style={styles.sheetSection}>
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
@@ -614,6 +807,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
|||||||
currentModeId: currentAgent.currentModeId,
|
currentModeId: currentAgent.currentModeId,
|
||||||
runtimeModelId: currentAgent.runtimeInfo?.model ?? null,
|
runtimeModelId: currentAgent.runtimeInfo?.model ?? null,
|
||||||
model: currentAgent.model,
|
model: currentAgent.model,
|
||||||
|
features: currentAgent.features,
|
||||||
thinkingOptionId: currentAgent.thinkingOptionId,
|
thinkingOptionId: currentAgent.thinkingOptionId,
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
@@ -782,6 +976,15 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
|||||||
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
|
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)}
|
isModelLoading={isProviderModelsQueryLoading(modelsQuery)}
|
||||||
disabled={!client}
|
disabled={!client}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
AgentPermissionResponse,
|
AgentPermissionResponse,
|
||||||
AgentPermissionRequest,
|
AgentPermissionRequest,
|
||||||
AgentSessionConfig,
|
AgentSessionConfig,
|
||||||
|
AgentFeature,
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
AgentMode,
|
AgentMode,
|
||||||
AgentCapabilityFlags,
|
AgentCapabilityFlags,
|
||||||
@@ -99,6 +100,7 @@ export interface Agent {
|
|||||||
title: string | null;
|
title: string | null;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
|
features?: AgentFeature[];
|
||||||
thinkingOptionId?: string | null;
|
thinkingOptionId?: string | null;
|
||||||
requiresAttention?: boolean;
|
requiresAttention?: boolean;
|
||||||
attentionReason?: "finished" | "error" | "permission" | null;
|
attentionReason?: "finished" | "error" | "permission" | null;
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
|
|||||||
title: snapshot.title ?? null,
|
title: snapshot.title ?? null,
|
||||||
cwd: snapshot.cwd,
|
cwd: snapshot.cwd,
|
||||||
model: snapshot.model ?? null,
|
model: snapshot.model ?? null,
|
||||||
|
features: snapshot.features,
|
||||||
thinkingOptionId: snapshot.thinkingOptionId ?? null,
|
thinkingOptionId: snapshot.thinkingOptionId ?? null,
|
||||||
requiresAttention: snapshot.requiresAttention ?? false,
|
requiresAttention: snapshot.requiresAttention ?? false,
|
||||||
attentionReason: snapshot.attentionReason ?? null,
|
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> {
|
async setAgentThinkingOption(agentId: string, thinkingOptionId: string | null): Promise<void> {
|
||||||
const requestId = this.createRequestId();
|
const requestId = this.createRequestId();
|
||||||
const message = SessionInboundMessageSchema.parse({
|
const message = SessionInboundMessageSchema.parse({
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import { randomUUID } from "node:crypto";
|
|||||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||||
import { AgentManager } from "./agent-manager.js";
|
import { AgentManager } from "./agent-manager.js";
|
||||||
import { AgentStorage } from "./agent-storage.js";
|
import { AgentStorage } from "./agent-storage.js";
|
||||||
|
import { buildConfigOverrides } from "../persistence-hooks.js";
|
||||||
import type {
|
import type {
|
||||||
AgentClient,
|
AgentClient,
|
||||||
|
AgentFeature,
|
||||||
AgentLaunchContext,
|
AgentLaunchContext,
|
||||||
AgentPersistenceHandle,
|
AgentPersistenceHandle,
|
||||||
AgentRunResult,
|
AgentRunResult,
|
||||||
@@ -197,6 +199,16 @@ class TestAgentSession implements AgentSession {
|
|||||||
async close(): Promise<void> {}
|
async close(): Promise<void> {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createFeature(overrides: Partial<AgentFeature> = {}): AgentFeature {
|
||||||
|
return {
|
||||||
|
type: "toggle",
|
||||||
|
id: "fast_mode",
|
||||||
|
label: "Fast mode",
|
||||||
|
value: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("AgentManager", () => {
|
describe("AgentManager", () => {
|
||||||
const logger = createTestLogger();
|
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 () => {
|
test("reloadAgentSession preserves timeline and does not force history replay", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
@@ -629,6 +736,119 @@ describe("AgentManager", () => {
|
|||||||
expect(live!.updatedAt.getTime()).toBeGreaterThan(Date.parse(before!.updatedAt));
|
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 () => {
|
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 workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-active-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { z } from "zod";
|
|||||||
import type {
|
import type {
|
||||||
AgentCapabilityFlags,
|
AgentCapabilityFlags,
|
||||||
AgentClient,
|
AgentClient,
|
||||||
|
AgentFeature,
|
||||||
AgentLaunchContext,
|
AgentLaunchContext,
|
||||||
AgentSlashCommand,
|
AgentSlashCommand,
|
||||||
AgentMode,
|
AgentMode,
|
||||||
@@ -168,6 +169,7 @@ type ManagedAgentBase = {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
availableModes: AgentMode[];
|
availableModes: AgentMode[];
|
||||||
|
features?: AgentFeature[];
|
||||||
currentModeId: string | null;
|
currentModeId: string | null;
|
||||||
pendingPermissions: Map<string, AgentPermissionRequest>;
|
pendingPermissions: Map<string, AgentPermissionRequest>;
|
||||||
pendingReplacement: boolean;
|
pendingReplacement: boolean;
|
||||||
@@ -940,6 +942,19 @@ export class AgentManager {
|
|||||||
this.emitState(agent);
|
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> {
|
async setTitle(agentId: string, title: string): Promise<void> {
|
||||||
const agent = this.requireAgent(agentId);
|
const agent = this.requireAgent(agentId);
|
||||||
const normalizedTitle = title.trim();
|
const normalizedTitle = title.trim();
|
||||||
@@ -2325,6 +2340,10 @@ export class AgentManager {
|
|||||||
// Keep attention as an edge-triggered unread signal, not a level signal.
|
// Keep attention as an edge-triggered unread signal, not a level signal.
|
||||||
this.checkAndSetAttention(agent);
|
this.checkAndSetAttention(agent);
|
||||||
|
|
||||||
|
if (agent.session?.features) {
|
||||||
|
agent.features = agent.session.features;
|
||||||
|
}
|
||||||
|
|
||||||
this.dispatch({
|
this.dispatch({
|
||||||
type: "agent_state",
|
type: "agent_state",
|
||||||
agent: { ...agent },
|
agent: { ...agent },
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { AGENT_LIFECYCLE_STATUSES, type AgentLifecycleStatus } from "./agent-manager.js";
|
import { AGENT_LIFECYCLE_STATUSES, type AgentLifecycleStatus } from "./agent-manager.js";
|
||||||
import { toAgentPayload, toStoredAgentRecord, type ManagedAgent } from "./agent-projections.js";
|
import { toAgentPayload, toStoredAgentRecord, type ManagedAgent } from "./agent-projections.js";
|
||||||
import type {
|
import type {
|
||||||
|
AgentFeature,
|
||||||
AgentPermissionRequest,
|
AgentPermissionRequest,
|
||||||
AgentPersistenceHandle,
|
AgentPersistenceHandle,
|
||||||
AgentSessionConfig,
|
AgentSessionConfig,
|
||||||
@@ -111,6 +112,16 @@ function createPermission(overrides: Partial<AgentPermissionRequest> = {}): Agen
|
|||||||
return { ...base, ...overrides };
|
return { ...base, ...overrides };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createFeature(overrides: Partial<AgentFeature> = {}): AgentFeature {
|
||||||
|
return {
|
||||||
|
type: "toggle",
|
||||||
|
id: "fast_mode",
|
||||||
|
label: "Fast mode",
|
||||||
|
value: true,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("toStoredAgentRecord", () => {
|
describe("toStoredAgentRecord", () => {
|
||||||
it("captures lifecycle metadata, config, and persistence", () => {
|
it("captures lifecycle metadata, config, and persistence", () => {
|
||||||
const agent = createManagedAgent({
|
const agent = createManagedAgent({
|
||||||
@@ -291,4 +302,13 @@ describe("toAgentPayload", () => {
|
|||||||
const payload = toAgentPayload(agent);
|
const payload = toAgentPayload(agent);
|
||||||
expect(payload).not.toHaveProperty("lastUsage");
|
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),
|
capabilities: cloneCapabilities(agent.capabilities),
|
||||||
currentModeId: agent.currentModeId,
|
currentModeId: agent.currentModeId,
|
||||||
availableModes: cloneAvailableModes(agent.availableModes),
|
availableModes: cloneAvailableModes(agent.availableModes),
|
||||||
|
features: agent.features,
|
||||||
pendingPermissions: sanitizePendingPermissions(agent.pendingPermissions),
|
pendingPermissions: sanitizePendingPermissions(agent.pendingPermissions),
|
||||||
persistence: sanitizePersistenceHandle(agent.persistence),
|
persistence: sanitizePersistenceHandle(agent.persistence),
|
||||||
title: options?.title ?? null,
|
title: options?.title ?? null,
|
||||||
@@ -139,6 +140,12 @@ function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentC
|
|||||||
if (config.thinkingOptionId) {
|
if (config.thinkingOptionId) {
|
||||||
serializable.thinkingOptionId = 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);
|
const extra = sanitizeMetadata(config.extra);
|
||||||
if (extra !== undefined) {
|
if (extra !== undefined) {
|
||||||
serializable.extra = extra;
|
serializable.extra = extra;
|
||||||
|
|||||||
@@ -64,6 +64,27 @@ export type AgentSelectOption = {
|
|||||||
metadata?: AgentMetadata;
|
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 = {
|
export type AgentCapabilityFlags = {
|
||||||
supportsStreaming: boolean;
|
supportsStreaming: boolean;
|
||||||
supportsSessionPersistence: boolean;
|
supportsSessionPersistence: boolean;
|
||||||
@@ -367,6 +388,7 @@ export type AgentSessionConfig = {
|
|||||||
modeId?: string;
|
modeId?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
thinkingOptionId?: string;
|
thinkingOptionId?: string;
|
||||||
|
featureValues?: Record<string, unknown>;
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
approvalPolicy?: string;
|
approvalPolicy?: string;
|
||||||
sandboxMode?: string;
|
sandboxMode?: string;
|
||||||
@@ -392,6 +414,7 @@ export interface AgentSession {
|
|||||||
readonly provider: AgentProvider;
|
readonly provider: AgentProvider;
|
||||||
readonly id: string | null;
|
readonly id: string | null;
|
||||||
readonly capabilities: AgentCapabilityFlags;
|
readonly capabilities: AgentCapabilityFlags;
|
||||||
|
readonly features?: AgentFeature[];
|
||||||
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
|
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
|
||||||
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
|
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
|
||||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
|
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
|
||||||
@@ -408,6 +431,7 @@ export interface AgentSession {
|
|||||||
listCommands?(): Promise<AgentSlashCommand[]>;
|
listCommands?(): Promise<AgentSlashCommand[]>;
|
||||||
setModel?(modelId: string | null): Promise<void>;
|
setModel?(modelId: string | null): Promise<void>;
|
||||||
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
|
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
|
||||||
|
setFeature?(featureId: string, value: unknown): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListModelsOptions {
|
export interface ListModelsOptions {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { promises as fs } from "node:fs";
|
|||||||
|
|
||||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||||
import { AgentStorage } from "./agent-storage.js";
|
import { AgentStorage } from "./agent-storage.js";
|
||||||
|
import { buildConfigOverrides, buildSessionConfig } from "../persistence-hooks.js";
|
||||||
import type { ManagedAgent } from "./agent-manager.js";
|
import type { ManagedAgent } from "./agent-manager.js";
|
||||||
import type {
|
import type {
|
||||||
AgentPermissionRequest,
|
AgentPermissionRequest,
|
||||||
@@ -41,6 +42,9 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
|
|||||||
systemPrompt: configOverrides.systemPrompt,
|
systemPrompt: configOverrides.systemPrompt,
|
||||||
mcpServers: configOverrides.mcpServers,
|
mcpServers: configOverrides.mcpServers,
|
||||||
};
|
};
|
||||||
|
if (Object.prototype.hasOwnProperty.call(configOverrides, "featureValues")) {
|
||||||
|
config.featureValues = configOverrides.featureValues;
|
||||||
|
}
|
||||||
const session = lifecycle === "closed" ? null : (overrides.session ?? ({} as AgentSession));
|
const session = lifecycle === "closed" ? null : (overrides.session ?? ({} as AgentSession));
|
||||||
const activeForegroundTurnId =
|
const activeForegroundTurnId =
|
||||||
overrides.activeForegroundTurnId ?? (lifecycle === "running" ? "test-turn-id" : null);
|
overrides.activeForegroundTurnId ?? (lifecycle === "running" ? "test-turn-id" : null);
|
||||||
@@ -150,6 +154,62 @@ describe("AgentStorage", () => {
|
|||||||
expect(persisted.config?.extra?.claude).toMatchObject({ maxThinkingTokens: 1024 });
|
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 () => {
|
test("applySnapshot preserves original createdAt timestamp", async () => {
|
||||||
const agentId = "agent-created-at";
|
const agentId = "agent-created-at";
|
||||||
const firstTimestamp = new Date("2025-01-01T00:00:00.000Z");
|
const firstTimestamp = new Date("2025-01-01T00:00:00.000Z");
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const SERIALIZABLE_CONFIG_SCHEMA = z
|
|||||||
modeId: z.string().nullable().optional(),
|
modeId: z.string().nullable().optional(),
|
||||||
model: z.string().nullable().optional(),
|
model: z.string().nullable().optional(),
|
||||||
thinkingOptionId: z.string().nullable().optional(),
|
thinkingOptionId: z.string().nullable().optional(),
|
||||||
|
featureValues: z.record(z.unknown()).nullable().optional(),
|
||||||
extra: z.record(z.any()).nullable().optional(),
|
extra: z.record(z.any()).nullable().optional(),
|
||||||
systemPrompt: z.string().nullable().optional(),
|
systemPrompt: z.string().nullable().optional(),
|
||||||
mcpServers: z.record(z.any()).nullable().optional(),
|
mcpServers: z.record(z.any()).nullable().optional(),
|
||||||
@@ -65,7 +66,14 @@ const STORED_AGENT_SCHEMA = z.object({
|
|||||||
|
|
||||||
export type SerializableAgentConfig = Pick<
|
export type SerializableAgentConfig = Pick<
|
||||||
AgentSessionConfig,
|
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>;
|
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 {
|
import type {
|
||||||
AgentCapabilityFlags,
|
AgentCapabilityFlags,
|
||||||
AgentClient,
|
AgentClient,
|
||||||
|
AgentFeature,
|
||||||
|
AgentFeatureToggle,
|
||||||
AgentLaunchContext,
|
AgentLaunchContext,
|
||||||
AgentMode,
|
AgentMode,
|
||||||
AgentModelDefinition,
|
AgentModelDefinition,
|
||||||
@@ -85,6 +87,20 @@ const CODEX_MODES: AgentMode[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_CODEX_MODE_ID = "auto";
|
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<
|
const MODE_PRESETS: Record<
|
||||||
string,
|
string,
|
||||||
@@ -140,6 +156,16 @@ function normalizeCodexModelLabel(displayName: string): string {
|
|||||||
return displayName.replace(/\bgpt\b/gi, "GPT");
|
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 = {
|
type CodexConfiguredDefaults = {
|
||||||
model?: string;
|
model?: string;
|
||||||
thinkingOptionId?: string;
|
thinkingOptionId?: string;
|
||||||
@@ -2019,11 +2045,6 @@ function buildCodexAppServerEnv(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const __codexAppServerInternals = {
|
|
||||||
buildCodexAppServerEnv,
|
|
||||||
mapCodexPatchNotificationToToolCall,
|
|
||||||
};
|
|
||||||
|
|
||||||
class CodexAppServerAgentSession implements AgentSession {
|
class CodexAppServerAgentSession implements AgentSession {
|
||||||
readonly provider = CODEX_PROVIDER;
|
readonly provider = CODEX_PROVIDER;
|
||||||
readonly capabilities = CODEX_APP_SERVER_CAPABILITIES;
|
readonly capabilities = CODEX_APP_SERVER_CAPABILITIES;
|
||||||
@@ -2038,6 +2059,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private nextTurnOrdinal = 0;
|
private nextTurnOrdinal = 0;
|
||||||
private activeForegroundTurnId: string | null = null;
|
private activeForegroundTurnId: string | null = null;
|
||||||
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
|
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
|
||||||
|
private serviceTier: "fast" | null = null;
|
||||||
private historyPending = false;
|
private historyPending = false;
|
||||||
private persistedHistory: AgentTimelineItem[] = [];
|
private persistedHistory: AgentTimelineItem[] = [];
|
||||||
private pendingPermissions = new Map<string, AgentPermissionRequest>();
|
private pendingPermissions = new Map<string, AgentPermissionRequest>();
|
||||||
@@ -2094,6 +2116,9 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
this.currentMode = config.modeId;
|
this.currentMode = config.modeId;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
|
this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
|
||||||
|
if (this.config.featureValues?.fast_mode) {
|
||||||
|
this.serviceTier = "fast";
|
||||||
|
}
|
||||||
|
|
||||||
if (this.resumeHandle?.sessionId) {
|
if (this.resumeHandle?.sessionId) {
|
||||||
this.currentThreadId = this.resumeHandle.sessionId;
|
this.currentThreadId = this.resumeHandle.sessionId;
|
||||||
@@ -2105,6 +2130,13 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
return this.currentThreadId;
|
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> {
|
async connect(): Promise<void> {
|
||||||
if (this.connected) return;
|
if (this.connected) return;
|
||||||
const child = this.spawnAppServer();
|
const child = this.spawnAppServer();
|
||||||
@@ -2495,6 +2527,9 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
if (thinkingOptionId) {
|
if (thinkingOptionId) {
|
||||||
params.effort = thinkingOptionId;
|
params.effort = thinkingOptionId;
|
||||||
}
|
}
|
||||||
|
if (this.serviceTier) {
|
||||||
|
params.serviceTier = this.serviceTier;
|
||||||
|
}
|
||||||
if (this.resolvedCollaborationMode) {
|
if (this.resolvedCollaborationMode) {
|
||||||
params.collaborationMode = {
|
params.collaborationMode = {
|
||||||
mode: this.resolvedCollaborationMode.mode,
|
mode: this.resolvedCollaborationMode.mode,
|
||||||
@@ -2586,6 +2621,9 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
|
|
||||||
async setModel(modelId: string | null): Promise<void> {
|
async setModel(modelId: string | null): Promise<void> {
|
||||||
this.config.model = modelId ?? undefined;
|
this.config.model = modelId ?? undefined;
|
||||||
|
if (!codexModelSupportsFastMode(this.config.model)) {
|
||||||
|
this.serviceTier = null;
|
||||||
|
}
|
||||||
this.resolvedCollaborationMode = this.resolveCollaborationMode(this.currentMode);
|
this.resolvedCollaborationMode = this.resolveCollaborationMode(this.currentMode);
|
||||||
this.cachedRuntimeInfo = null;
|
this.cachedRuntimeInfo = null;
|
||||||
}
|
}
|
||||||
@@ -2596,6 +2634,15 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
this.cachedRuntimeInfo = null;
|
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[] {
|
getPendingPermissions(): AgentPermissionRequest[] {
|
||||||
return Array.from(this.pendingPermissions.values());
|
return Array.from(this.pendingPermissions.values());
|
||||||
}
|
}
|
||||||
@@ -3636,3 +3683,10 @@ export class CodexAppServerAgentClient implements AgentClient {
|
|||||||
return true;
|
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,
|
modeId: record.lastModeId ?? record.config?.modeId ?? undefined,
|
||||||
model: record.config?.model ?? undefined,
|
model: record.config?.model ?? undefined,
|
||||||
thinkingOptionId: record.config?.thinkingOptionId ?? undefined,
|
thinkingOptionId: record.config?.thinkingOptionId ?? undefined,
|
||||||
|
featureValues: record.config?.featureValues ?? undefined,
|
||||||
title: record.config?.title ?? undefined,
|
title: record.config?.title ?? undefined,
|
||||||
extra: record.config?.extra ?? undefined,
|
extra: record.config?.extra ?? undefined,
|
||||||
systemPrompt: record.config?.systemPrompt ?? undefined,
|
systemPrompt: record.config?.systemPrompt ?? undefined,
|
||||||
@@ -61,6 +62,7 @@ export function buildSessionConfig(record: StoredAgentRecord): AgentSessionConfi
|
|||||||
modeId: overrides.modeId,
|
modeId: overrides.modeId,
|
||||||
model: overrides.model,
|
model: overrides.model,
|
||||||
thinkingOptionId: overrides.thinkingOptionId,
|
thinkingOptionId: overrides.thinkingOptionId,
|
||||||
|
featureValues: overrides.featureValues,
|
||||||
title: overrides.title,
|
title: overrides.title,
|
||||||
extra: overrides.extra,
|
extra: overrides.extra,
|
||||||
systemPrompt: overrides.systemPrompt,
|
systemPrompt: overrides.systemPrompt,
|
||||||
|
|||||||
@@ -1565,6 +1565,15 @@ export class Session {
|
|||||||
await this.handleSetAgentModelRequest(msg.agentId, msg.modelId, msg.requestId);
|
await this.handleSetAgentModelRequest(msg.agentId, msg.modelId, msg.requestId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "set_agent_feature_request":
|
||||||
|
await this.handleSetAgentFeatureRequest(
|
||||||
|
msg.agentId,
|
||||||
|
msg.featureId,
|
||||||
|
msg.value,
|
||||||
|
msg.requestId,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
case "set_agent_thinking_request":
|
case "set_agent_thinking_request":
|
||||||
await this.handleSetAgentThinkingRequest(
|
await this.handleSetAgentThinkingRequest(
|
||||||
msg.agentId,
|
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(
|
private async handleSetAgentThinkingRequest(
|
||||||
agentId: string,
|
agentId: string,
|
||||||
thinkingOptionId: string | null,
|
thinkingOptionId: string | null,
|
||||||
|
|||||||
@@ -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(),
|
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({
|
const AgentModelDefinitionSchema: z.ZodType<AgentModelDefinition> = z.object({
|
||||||
provider: AgentProviderSchema,
|
provider: AgentProviderSchema,
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -135,6 +159,7 @@ const AgentSessionConfigSchema = z.object({
|
|||||||
modeId: z.string().optional(),
|
modeId: z.string().optional(),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
thinkingOptionId: 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(),
|
title: z.string().trim().min(1).max(MAX_EXPLICIT_AGENT_TITLE_CHARS).optional().nullable(),
|
||||||
approvalPolicy: z.string().optional(),
|
approvalPolicy: z.string().optional(),
|
||||||
sandboxMode: z.string().optional(),
|
sandboxMode: z.string().optional(),
|
||||||
@@ -458,6 +483,7 @@ export const AgentSnapshotPayloadSchema = z.object({
|
|||||||
provider: AgentProviderSchema,
|
provider: AgentProviderSchema,
|
||||||
cwd: z.string(),
|
cwd: z.string(),
|
||||||
model: z.string().nullable(),
|
model: z.string().nullable(),
|
||||||
|
features: z.array(AgentFeatureSchema).optional(),
|
||||||
thinkingOptionId: z.string().nullable().optional(),
|
thinkingOptionId: z.string().nullable().optional(),
|
||||||
effectiveThinkingOptionId: z.string().nullable().optional(),
|
effectiveThinkingOptionId: z.string().nullable().optional(),
|
||||||
createdAt: z.string(),
|
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({
|
export const UpdateAgentResponseMessageSchema = z.object({
|
||||||
type: z.literal("update_agent_response"),
|
type: z.literal("update_agent_response"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
@@ -1226,6 +1270,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
SetAgentModeRequestMessageSchema,
|
SetAgentModeRequestMessageSchema,
|
||||||
SetAgentModelRequestMessageSchema,
|
SetAgentModelRequestMessageSchema,
|
||||||
SetAgentThinkingRequestMessageSchema,
|
SetAgentThinkingRequestMessageSchema,
|
||||||
|
SetAgentFeatureRequestMessageSchema,
|
||||||
AgentPermissionResponseMessageSchema,
|
AgentPermissionResponseMessageSchema,
|
||||||
CheckoutStatusRequestSchema,
|
CheckoutStatusRequestSchema,
|
||||||
SubscribeCheckoutDiffRequestSchema,
|
SubscribeCheckoutDiffRequestSchema,
|
||||||
@@ -2310,6 +2355,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
SetAgentModeResponseMessageSchema,
|
SetAgentModeResponseMessageSchema,
|
||||||
SetAgentModelResponseMessageSchema,
|
SetAgentModelResponseMessageSchema,
|
||||||
SetAgentThinkingResponseMessageSchema,
|
SetAgentThinkingResponseMessageSchema,
|
||||||
|
SetAgentFeatureResponseMessageSchema,
|
||||||
UpdateAgentResponseMessageSchema,
|
UpdateAgentResponseMessageSchema,
|
||||||
WaitForFinishResponseMessageSchema,
|
WaitForFinishResponseMessageSchema,
|
||||||
AgentPermissionRequestMessageSchema,
|
AgentPermissionRequestMessageSchema,
|
||||||
@@ -2398,6 +2444,10 @@ export type FetchAgentTimelineResponseMessage = z.infer<
|
|||||||
>;
|
>;
|
||||||
export type SendAgentMessageResponseMessage = z.infer<typeof SendAgentMessageResponseMessageSchema>;
|
export type SendAgentMessageResponseMessage = z.infer<typeof SendAgentMessageResponseMessageSchema>;
|
||||||
export type SetVoiceModeResponseMessage = z.infer<typeof SetVoiceModeResponseMessageSchema>;
|
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 UpdateAgentResponseMessage = z.infer<typeof UpdateAgentResponseMessageSchema>;
|
||||||
export type WaitForFinishResponseMessage = z.infer<typeof WaitForFinishResponseMessageSchema>;
|
export type WaitForFinishResponseMessage = z.infer<typeof WaitForFinishResponseMessageSchema>;
|
||||||
export type AgentPermissionRequestMessage = z.infer<typeof AgentPermissionRequestMessageSchema>;
|
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 SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessageSchema>;
|
||||||
export type SetAgentModelRequestMessage = z.infer<typeof SetAgentModelRequestMessageSchema>;
|
export type SetAgentModelRequestMessage = z.infer<typeof SetAgentModelRequestMessageSchema>;
|
||||||
export type SetAgentThinkingRequestMessage = z.infer<typeof SetAgentThinkingRequestMessageSchema>;
|
export type SetAgentThinkingRequestMessage = z.infer<typeof SetAgentThinkingRequestMessageSchema>;
|
||||||
|
export type SetAgentFeatureRequestMessage = z.infer<typeof SetAgentFeatureRequestMessageSchema>;
|
||||||
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
|
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
|
||||||
export type CheckoutStatusRequest = z.infer<typeof CheckoutStatusRequestSchema>;
|
export type CheckoutStatusRequest = z.infer<typeof CheckoutStatusRequestSchema>;
|
||||||
export type CheckoutStatusResponse = z.infer<typeof CheckoutStatusResponseSchema>;
|
export type CheckoutStatusResponse = z.infer<typeof CheckoutStatusResponseSchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user