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

Introduce a generic feature system where providers declare dynamic
features (toggles/selects) and the app renders controls automatically.
One message pair (set_agent_feature_request/response) handles all
feature mutations. Feature values persist and restore on agent resume.

First consumer: Codex fast mode (service_tier) — gated to supported
model families, with proper cleanup on model switch.
This commit is contained in:
Mohamed Boudra
2026-04-04 15:25:20 +08:00
committed by GitHub
parent 99114ddd11
commit 42cfed514c
17 changed files with 1081 additions and 8 deletions
@@ -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");
});
});
+51
View File
@@ -77,6 +77,30 @@ const AgentSelectOptionSchema = z.object({
metadata: z.record(z.unknown()).optional(),
});
export const AgentFeatureToggleSchema = z.object({
type: z.literal("toggle"),
id: z.string(),
label: z.string(),
description: z.string().optional(),
icon: z.string().optional(),
value: z.boolean(),
});
export const AgentFeatureSelectSchema = z.object({
type: z.literal("select"),
id: z.string(),
label: z.string(),
description: z.string().optional(),
icon: z.string().optional(),
value: z.string().nullable(),
options: z.array(AgentSelectOptionSchema),
});
export const AgentFeatureSchema = z.discriminatedUnion("type", [
AgentFeatureToggleSchema,
AgentFeatureSelectSchema,
]);
const AgentModelDefinitionSchema: z.ZodType<AgentModelDefinition> = z.object({
provider: AgentProviderSchema,
id: z.string(),
@@ -135,6 +159,7 @@ const AgentSessionConfigSchema = z.object({
modeId: z.string().optional(),
model: z.string().optional(),
thinkingOptionId: z.string().optional(),
featureValues: z.record(z.unknown()).optional(),
title: z.string().trim().min(1).max(MAX_EXPLICIT_AGENT_TITLE_CHARS).optional().nullable(),
approvalPolicy: z.string().optional(),
sandboxMode: z.string().optional(),
@@ -458,6 +483,7 @@ export const AgentSnapshotPayloadSchema = z.object({
provider: AgentProviderSchema,
cwd: z.string(),
model: z.string().nullable(),
features: z.array(AgentFeatureSchema).optional(),
thinkingOptionId: z.string().nullable().optional(),
effectiveThinkingOptionId: z.string().nullable().optional(),
createdAt: z.string(),
@@ -837,6 +863,24 @@ export const SetAgentThinkingResponseMessageSchema = z.object({
}),
});
export const SetAgentFeatureRequestMessageSchema = z.object({
type: z.literal("set_agent_feature_request"),
agentId: z.string(),
featureId: z.string(),
value: z.unknown(),
requestId: z.string(),
});
export const SetAgentFeatureResponseMessageSchema = z.object({
type: z.literal("set_agent_feature_response"),
payload: z.object({
requestId: z.string(),
agentId: z.string(),
accepted: z.boolean(),
error: z.string().nullable(),
}),
});
export const UpdateAgentResponseMessageSchema = z.object({
type: z.literal("update_agent_response"),
payload: z.object({
@@ -1226,6 +1270,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
SetAgentModeRequestMessageSchema,
SetAgentModelRequestMessageSchema,
SetAgentThinkingRequestMessageSchema,
SetAgentFeatureRequestMessageSchema,
AgentPermissionResponseMessageSchema,
CheckoutStatusRequestSchema,
SubscribeCheckoutDiffRequestSchema,
@@ -2310,6 +2355,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
SetAgentModeResponseMessageSchema,
SetAgentModelResponseMessageSchema,
SetAgentThinkingResponseMessageSchema,
SetAgentFeatureResponseMessageSchema,
UpdateAgentResponseMessageSchema,
WaitForFinishResponseMessageSchema,
AgentPermissionRequestMessageSchema,
@@ -2398,6 +2444,10 @@ export type FetchAgentTimelineResponseMessage = z.infer<
>;
export type SendAgentMessageResponseMessage = z.infer<typeof SendAgentMessageResponseMessageSchema>;
export type SetVoiceModeResponseMessage = z.infer<typeof SetVoiceModeResponseMessageSchema>;
export type SetAgentModeResponseMessage = z.infer<typeof SetAgentModeResponseMessageSchema>;
export type SetAgentModelResponseMessage = z.infer<typeof SetAgentModelResponseMessageSchema>;
export type SetAgentThinkingResponseMessage = z.infer<typeof SetAgentThinkingResponseMessageSchema>;
export type SetAgentFeatureResponseMessage = z.infer<typeof SetAgentFeatureResponseMessageSchema>;
export type UpdateAgentResponseMessage = z.infer<typeof UpdateAgentResponseMessageSchema>;
export type WaitForFinishResponseMessage = z.infer<typeof WaitForFinishResponseMessageSchema>;
export type AgentPermissionRequestMessage = z.infer<typeof AgentPermissionRequestMessageSchema>;
@@ -2479,6 +2529,7 @@ export type UpdateAgentRequestMessage = z.infer<typeof UpdateAgentRequestMessage
export type SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessageSchema>;
export type SetAgentModelRequestMessage = z.infer<typeof SetAgentModelRequestMessageSchema>;
export type SetAgentThinkingRequestMessage = z.infer<typeof SetAgentThinkingRequestMessageSchema>;
export type SetAgentFeatureRequestMessage = z.infer<typeof SetAgentFeatureRequestMessageSchema>;
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
export type CheckoutStatusRequest = z.infer<typeof CheckoutStatusRequestSchema>;
export type CheckoutStatusResponse = z.infer<typeof CheckoutStatusResponseSchema>;