Add MCP provider feature controls (#909)

Refs #835
This commit is contained in:
Bolun Zhang
2026-05-13 20:05:52 +08:00
committed by GitHub
parent d30a3a72da
commit 15631b815b
8 changed files with 482 additions and 65 deletions

View File

@@ -34,6 +34,17 @@ function recordArr(val: unknown): StructuredContent[] {
return z.array(z.record(z.unknown())).parse(val); return z.array(z.record(z.unknown())).parse(val);
} }
function expectAgentFeatureValue(snapshot: StructuredContent, featureId: string, value: unknown) {
expect(recordArr(snapshot.features)).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: featureId,
value,
}),
]),
);
}
function strArrOptional(val: unknown): string[] | undefined { function strArrOptional(val: unknown): string[] | undefined {
return z.array(z.string()).optional().parse(val); return z.array(z.string()).optional().parse(val);
} }
@@ -325,6 +336,60 @@ describe("Suite A: Core Fixes", () => {
} }
}); });
test("create_agent accepts provider features over MCP", async () => {
let agentId: string | null = null;
try {
agentId = await createTopLevelAgent({ features: { test_feature: true } });
const internalSnapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
expect(internalSnapshot?.config.featureValues).toEqual({ test_feature: true });
const status = await callToolStructured(topLevelClient, "get_agent_status", { agentId });
const snapshot = z.record(z.unknown()).parse(status.snapshot);
expectAgentFeatureValue(snapshot, "test_feature", true);
} finally {
await archiveAgentIfPresent(agentId);
}
});
test("agent-scoped create_agent accepts provider features over MCP", async () => {
let agentId: string | null = null;
try {
agentId = await createChildAgent({
provider: "claude/claude-test-model",
features: { test_feature: true },
});
const internalSnapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
expect(internalSnapshot?.config.featureValues).toEqual({ test_feature: true });
const status = await callToolStructured(topLevelClient, "get_agent_status", { agentId });
const snapshot = z.record(z.unknown()).parse(status.snapshot);
expectAgentFeatureValue(snapshot, "test_feature", true);
} finally {
await archiveAgentIfPresent(agentId);
}
});
test("set_agent_feature updates provider features over MCP", async () => {
let agentId: string | null = null;
try {
agentId = await createTopLevelAgent({ features: { test_feature: false } });
const updated = await callToolStructured(topLevelClient, "set_agent_feature", {
agentId,
featureId: "test_feature",
value: true,
});
expect(updated.success).toBe(true);
const internalSnapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
expect(internalSnapshot?.config.featureValues).toEqual({ test_feature: true });
const status = await callToolStructured(topLevelClient, "get_agent_status", { agentId });
const snapshot = z.record(z.unknown()).parse(status.snapshot);
expectAgentFeatureValue(snapshot, "test_feature", true);
} finally {
await archiveAgentIfPresent(agentId);
}
});
test("create_agent accepts labels param", async () => { test("create_agent accepts labels param", async () => {
let agentId: string | null = null; let agentId: string | null = null;
try { try {
@@ -480,6 +545,7 @@ describe("Suite C: Schedule Tools", () => {
prompt: "say hello", prompt: "say hello",
every: "5m", every: "5m",
name: "Parity schedule list", name: "Parity schedule list",
provider: "claude",
}); });
scheduleId = str(created.id); scheduleId = str(created.id);
@@ -527,6 +593,7 @@ describe("Suite C: Schedule Tools", () => {
prompt: "say hello", prompt: "say hello",
every: "5m", every: "5m",
name: "Parity inspect schedule", name: "Parity inspect schedule",
provider: "claude",
}); });
scheduleId = str(created.id); scheduleId = str(created.id);
@@ -551,6 +618,7 @@ describe("Suite C: Schedule Tools", () => {
prompt: "say hello", prompt: "say hello",
every: "5m", every: "5m",
name: "Parity pause schedule", name: "Parity pause schedule",
provider: "claude",
}); });
scheduleId = str(created.id); scheduleId = str(created.id);
@@ -577,6 +645,7 @@ describe("Suite C: Schedule Tools", () => {
prompt: "say hello", prompt: "say hello",
every: "5m", every: "5m",
name: "Parity delete schedule", name: "Parity delete schedule",
provider: "claude",
}); });
scheduleId = str(created.id); scheduleId = str(created.id);

View File

@@ -47,6 +47,20 @@ interface LooseStructuredContent {
interface RegisteredMcpTool { interface RegisteredMcpTool {
inputSchema: LooseInputSchema; inputSchema: LooseInputSchema;
callback?: (
input: unknown,
extra?: unknown,
) => Promise<{
structuredContent: LooseStructuredContent;
content?: Array<{ type: string; text?: string }>;
}>;
handler?: (input: unknown) => Promise<{
structuredContent: LooseStructuredContent;
content?: Array<{ type: string; text?: string }>;
}>;
}
interface RegisteredMcpToolWithHandler extends RegisteredMcpTool {
handler: (input: unknown) => Promise<{ handler: (input: unknown) => Promise<{
structuredContent: LooseStructuredContent; structuredContent: LooseStructuredContent;
content?: Array<{ type: string; text?: string }>; content?: Array<{ type: string; text?: string }>;
@@ -64,12 +78,16 @@ function lookupTool(
function registeredTool( function registeredTool(
server: Awaited<ReturnType<typeof createAgentMcpServer>>, server: Awaited<ReturnType<typeof createAgentMcpServer>>,
name: string, name: string,
): RegisteredMcpTool { ): RegisteredMcpToolWithHandler {
const tool = lookupTool(server, name); const tool = lookupTool(server, name);
if (!tool) { if (!tool) {
throw new Error(`MCP tool not registered: ${name}`); throw new Error(`MCP tool not registered: ${name}`);
} }
return tool; const handler = tool.handler ?? tool.callback;
if (!handler) {
throw new Error(`MCP tool has no callable handler: ${name}`);
}
return { ...tool, handler };
} }
function agentsOf(response: { function agentsOf(response: {
@@ -96,6 +114,7 @@ function buildAgentManagerSpies() {
waitForAgentEvent: vi.fn(), waitForAgentEvent: vi.fn(),
recordUserMessage: vi.fn(), recordUserMessage: vi.fn(),
setAgentMode: vi.fn(), setAgentMode: vi.fn(),
setAgentFeature: vi.fn().mockResolvedValue(undefined),
setLabels: vi.fn().mockResolvedValue(undefined), setLabels: vi.fn().mockResolvedValue(undefined),
setTitle: vi.fn().mockResolvedValue(undefined), setTitle: vi.fn().mockResolvedValue(undefined),
archiveAgent: vi.fn().mockResolvedValue({ archivedAt: new Date().toISOString() }), archiveAgent: vi.fn().mockResolvedValue({ archivedAt: new Date().toISOString() }),
@@ -467,6 +486,44 @@ describe("create_agent MCP tool", () => {
).toBe(true); ).toBe(true);
}); });
it("accepts provider features and passes them through createAgent", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({
id: "feature-agent",
cwd: REPO_CWD,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Feature test", featureValues: { fast_mode: true } },
} as ManagedAgent);
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = registeredTool(server, "create_agent");
const input = {
cwd: existingCwd,
title: "Feature test",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
background: true,
features: { fast_mode: true },
};
const parsed = await tool.inputSchema.safeParseAsync(input);
expect(parsed.success).toBe(true);
await tool.handler(input);
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({
provider: "codex",
model: "gpt-5.4",
featureValues: { fast_mode: true },
}),
undefined,
undefined,
);
});
it("requires provider as provider/model and rejects the old model field", async () => { it("requires provider as provider/model and rejects the old model field", async () => {
const { agentManager, agentStorage } = createTestDeps(); const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
@@ -1269,6 +1326,58 @@ describe("create_agent MCP tool", () => {
await rm(baseDir, { recursive: true, force: true }); await rm(baseDir, { recursive: true, force: true });
}); });
it("accepts provider features from caller agents and passes them through createAgent", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({
id: "parent-agent",
cwd: existingCwd,
provider: "claude",
currentModeId: "bypassPermissions",
} as ManagedAgent);
spies.agentManager.createAgent.mockResolvedValue({
id: "child-agent",
cwd: existingCwd,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Child", featureValues: { fast_mode: true } },
} as ManagedAgent);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
callerAgentId: "parent-agent",
logger,
});
const tool = registeredTool(server, "create_agent");
const input = {
title: "Child",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
background: true,
features: { fast_mode: true },
};
const parsed = await tool.inputSchema.safeParseAsync(input);
expect(parsed.success).toBe(true);
await tool.handler(input);
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({
provider: "codex",
model: "gpt-5.4",
featureValues: { fast_mode: true },
}),
undefined,
{
labels: {
[PARENT_AGENT_ID_LABEL]: "parent-agent",
},
},
);
});
it("delegates MCP injection to AgentManager and passes through an undefined agent ID", async () => { it("delegates MCP injection to AgentManager and passes through an undefined agent ID", async () => {
const { agentManager, agentStorage, spies } = createTestDeps(); const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({ spies.agentManager.createAgent.mockResolvedValue({
@@ -1465,6 +1574,29 @@ describe("create_agent MCP tool", () => {
}); });
}); });
describe("set_agent_feature MCP tool", () => {
const logger = createTestLogger();
it("sets a provider feature on an existing agent", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = registeredTool(server, "set_agent_feature");
const input = {
agentId: "agent-1",
featureId: "fast_mode",
value: true,
};
const parsed = await tool.inputSchema.safeParseAsync(input);
expect(parsed.success).toBe(true);
const response = await tool.handler(input);
expect(spies.agentManager.setAgentFeature).toHaveBeenCalledWith("agent-1", "fast_mode", true);
expect(response.structuredContent).toEqual({ success: true });
});
});
describe("create_schedule MCP tool", () => { describe("create_schedule MCP tool", () => {
const logger = createTestLogger(); const logger = createTestLogger();

View File

@@ -489,6 +489,10 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
"Required provider/model pair, for example codex/gpt-5.4.", "Required provider/model pair, for example codex/gpt-5.4.",
), ),
thinking: z.string().optional().describe("Thinking option ID"), thinking: z.string().optional().describe("Thinking option ID"),
features: z
.record(z.unknown())
.optional()
.describe("Provider-specific feature values, for example { fast_mode: true } for Codex."),
labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"), labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"),
initialPrompt: z initialPrompt: z
.string() .string()
@@ -531,6 +535,10 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
"Required provider/model pair, for example codex/gpt-5.4.", "Required provider/model pair, for example codex/gpt-5.4.",
), ),
thinking: z.string().optional().describe("Thinking option ID"), thinking: z.string().optional().describe("Thinking option ID"),
features: z
.record(z.unknown())
.optional()
.describe("Provider-specific feature values, for example { fast_mode: true } for Codex."),
labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"), labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"),
initialPrompt: z initialPrompt: z
.string() .string()
@@ -630,6 +638,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
normalizedTitle: string | null; normalizedTitle: string | null;
model: string | undefined; model: string | undefined;
thinking: string | undefined; thinking: string | undefined;
features: Record<string, unknown> | undefined;
labels: Record<string, string> | undefined; labels: Record<string, string> | undefined;
notifyOnFinish: boolean; notifyOnFinish: boolean;
resolvedCwd: string; resolvedCwd: string;
@@ -699,6 +708,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
normalizedTitle: callerArgs.title.trim(), normalizedTitle: callerArgs.title.trim(),
model: resolvedProviderModel.model, model: resolvedProviderModel.model,
thinking: callerArgs.thinking, thinking: callerArgs.thinking,
features: callerArgs.features,
labels: callerArgs.labels, labels: callerArgs.labels,
notifyOnFinish: callerArgs.notifyOnFinish ?? false, notifyOnFinish: callerArgs.notifyOnFinish ?? false,
resolvedCwd, resolvedCwd,
@@ -772,6 +782,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
normalizedTitle: topLevelArgs.title.trim(), normalizedTitle: topLevelArgs.title.trim(),
model: resolvedProviderModel.model, model: resolvedProviderModel.model,
thinking: topLevelArgs.thinking, thinking: topLevelArgs.thinking,
features: topLevelArgs.features,
labels: topLevelArgs.labels, labels: topLevelArgs.labels,
notifyOnFinish: topLevelArgs.notifyOnFinish ?? false, notifyOnFinish: topLevelArgs.notifyOnFinish ?? false,
resolvedCwd, resolvedCwd,
@@ -815,6 +826,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
normalizedTitle, normalizedTitle,
model, model,
thinking, thinking,
features,
labels, labels,
notifyOnFinish, notifyOnFinish,
resolvedCwd, resolvedCwd,
@@ -836,6 +848,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
title: normalizedTitle ?? undefined, title: normalizedTitle ?? undefined,
model, model,
thinkingOptionId: thinking, thinkingOptionId: thinking,
featureValues: features,
}, },
undefined, undefined,
Object.keys(mergedLabels).length > 0 ? { labels: mergedLabels } : undefined, Object.keys(mergedLabels).length > 0 ? { labels: mergedLabels } : undefined,
@@ -923,6 +936,29 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
}, },
); );
server.registerTool(
"set_agent_feature",
{
title: "Set agent feature",
description: "Set a provider-specific feature on an existing agent, such as Codex fast_mode.",
inputSchema: {
agentId: z.string(),
featureId: z.string().trim().min(1),
value: z.unknown(),
},
outputSchema: {
success: z.boolean(),
},
},
async ({ agentId, featureId, value }) => {
await agentManager.setAgentFeature(agentId, featureId, value);
return {
content: [],
structuredContent: ensureValidJson({ success: true }),
};
},
);
server.registerTool( server.registerTool(
"wait_for_agent", "wait_for_agent",
{ {

View File

@@ -1,3 +1,4 @@
import pino from "pino";
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import type { AgentSession, AgentSessionConfig } from "../agent-sdk-types.js"; import type { AgentSession, AgentSessionConfig } from "../agent-sdk-types.js";
@@ -33,6 +34,25 @@ const TEST_COLLABORATION_MODES: CollaborationModeRecord[] = [
type CodexFeaturesTestSession = AgentSession; type CodexFeaturesTestSession = AgentSession;
interface CapturedLogEntry {
level?: number;
msg?: string;
[key: string]: unknown;
}
function createCapturedLogger(): { logger: pino.Logger; entries: CapturedLogEntry[] } {
const entries: CapturedLogEntry[] = [];
const logger = pino(
{ level: "debug" },
{
write(line: string) {
entries.push(JSON.parse(line) as CapturedLogEntry);
},
},
);
return { logger, entries };
}
function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSessionConfig { function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSessionConfig {
return { return {
provider: CODEX_PROVIDER, provider: CODEX_PROVIDER,
@@ -43,7 +63,10 @@ function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSession
}; };
} }
function createSessionHarness(configOverrides: Partial<AgentSessionConfig> = {}): { function createSessionHarness(
configOverrides: Partial<AgentSessionConfig> = {},
options: { logger?: pino.Logger } = {},
): {
session: CodexFeaturesTestSession; session: CodexFeaturesTestSession;
appServer: FakeCodexAppServer; appServer: FakeCodexAppServer;
} { } {
@@ -54,17 +77,20 @@ function createSessionHarness(configOverrides: Partial<AgentSessionConfig> = {})
const session = new __codexAppServerInternals.CodexAppServerAgentSession( const session = new __codexAppServerInternals.CodexAppServerAgentSession(
{ ...config, provider: CODEX_PROVIDER }, { ...config, provider: CODEX_PROVIDER },
null, null,
createTestLogger(), options.logger ?? createTestLogger(),
async () => appServer.child, async () => appServer.child,
) as CodexFeaturesTestSession; ) as CodexFeaturesTestSession;
return { session, appServer }; return { session, appServer };
} }
async function createConnectedSession(configOverrides: Partial<AgentSessionConfig> = {}): Promise<{ async function createConnectedSession(
configOverrides: Partial<AgentSessionConfig> = {},
options: { logger?: pino.Logger } = {},
): Promise<{
session: CodexFeaturesTestSession; session: CodexFeaturesTestSession;
appServer: FakeCodexAppServer; appServer: FakeCodexAppServer;
}> { }> {
const harness = createSessionHarness(configOverrides); const harness = createSessionHarness(configOverrides, options);
await harness.session.connect(); await harness.session.connect();
harness.appServer.assertNoErrors(); harness.appServer.assertNoErrors();
return harness; return harness;
@@ -136,6 +162,30 @@ describe("Codex app-server provider features", () => {
]); ]);
}); });
test("constructor ignores restored fast mode when model does not support it", async () => {
const { session, appServer } = await createConnectedSession({
model: "gpt-3.5-turbo",
featureValues: { fast_mode: true },
});
expect(session.features).toEqual([
{
type: "toggle",
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
value: false,
},
]);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({
serviceTier: expect.anything(),
});
});
test("setFeature('fast_mode', true) sets serviceTier to fast", async () => { test("setFeature('fast_mode', true) sets serviceTier to fast", async () => {
const { session, appServer } = await createConnectedSession(); const { session, appServer } = await createConnectedSession();
@@ -160,6 +210,14 @@ describe("Codex app-server provider features", () => {
}); });
}); });
test("setFeature('fast_mode', true) rejects models that do not support fast mode", async () => {
const { session } = await createConnectedSession({ model: "gpt-3.5-turbo" });
await expect(session.setFeature?.("fast_mode", true)).rejects.toThrow(
"Codex fast mode is not available for model 'gpt-3.5-turbo'",
);
});
test("setFeature invalidates runtime info", async () => { test("setFeature invalidates runtime info", async () => {
const { session } = await createConnectedSession(); const { session } = await createConnectedSession();
@@ -228,6 +286,30 @@ describe("Codex app-server provider features", () => {
}); });
}); });
test("startTurn logs a sanitized turn/start summary for fast mode observability", async () => {
const capture = createCapturedLogger();
const prompt = "secret prompt text should not be logged";
const { session } = await createConnectedSession(
{ featureValues: { fast_mode: true } },
{ logger: capture.logger },
);
await session.startTurn(prompt);
const entry = capture.entries.find(
(candidate) => candidate.msg === "Starting Codex app-server turn",
);
expect(entry).toMatchObject({
level: 30,
msg: "Starting Codex app-server turn",
model: "gpt-5.4",
modeId: "auto",
serviceTier: "fast",
cwd: "/tmp/codex-fast-mode-test",
});
expect(JSON.stringify(entry)).not.toContain(prompt);
});
test("setModel clears fast mode when switching to an unsupported model", async () => { test("setModel clears fast mode when switching to an unsupported model", async () => {
const { session, appServer } = await createConnectedSession(); const { session, appServer } = await createConnectedSession();

View File

@@ -2714,7 +2714,7 @@ 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) { if (this.config.featureValues?.fast_mode && codexModelSupportsFastMode(this.config.model)) {
this.serviceTier = "fast"; this.serviceTier = "fast";
} }
if (this.config.featureValues?.plan_mode) { if (this.config.featureValues?.plan_mode) {
@@ -3109,50 +3109,18 @@ class CodexAppServerAgentSession implements AgentSession {
return args ? `$${commandName} ${args}` : `$${commandName}`; return args ? `$${commandName} ${args}` : `$${commandName}`;
} }
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> { private async buildTurnStartParams(
return runProviderTurn({ prompt: CodexPromptInput,
prompt,
runOptions: options,
startTurn: (p, o) => this.startTurn(p, o),
subscribe: (callback) => this.subscribe(callback),
getSessionId: async () => (await this.getRuntimeInfo()).sessionId ?? "",
reduceFinalText: ({ current, item }) => {
if (item.type === "assistant_message") {
return item.text;
}
if (item.type === "tool_call" && item.detail.type === "plan") {
return item.detail.text;
}
return current;
},
});
}
async startTurn(
prompt: AgentPromptInput,
options?: AgentRunOptions, options?: AgentRunOptions,
): Promise<{ turnId: string }> { ): Promise<{
if (this.activeForegroundTurnId) { params: Record<string, unknown>;
throw new Error("A foreground turn is already active"); thinkingOptionId?: string;
} approvalPolicy: string;
sandboxPolicyType: string;
await this.connect(); hasOutputSchema: boolean;
if (!this.client) { hasCodexConfig: boolean;
throw new Error("Codex client not initialized"); }> {
} const input = await this.buildUserInput(prompt);
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
const effectivePrompt = slashCommand
? await this.buildCommandPromptInput(slashCommand.commandName, slashCommand.args)
: prompt;
if (this.currentThreadId) {
await this.ensureThreadLoaded();
} else {
await this.ensureThread();
}
const input = await this.buildUserInput(effectivePrompt);
const preset = MODE_PRESETS[this.currentMode] ?? MODE_PRESETS[DEFAULT_CODEX_MODE_ID]; const preset = MODE_PRESETS[this.currentMode] ?? MODE_PRESETS[DEFAULT_CODEX_MODE_ID];
const approvalPolicy = this.config.approvalPolicy ?? preset.approvalPolicy; const approvalPolicy = this.config.approvalPolicy ?? preset.approvalPolicy;
const sandboxPolicyType = this.config.sandboxMode ?? preset.sandbox; const sandboxPolicyType = this.config.sandboxMode ?? preset.sandbox;
@@ -3200,11 +3168,109 @@ class CodexAppServerAgentSession implements AgentSession {
params.config = codexConfig; params.config = codexConfig;
} }
return {
params,
thinkingOptionId,
approvalPolicy,
sandboxPolicyType,
hasOutputSchema: Boolean(options?.outputSchema),
hasCodexConfig: Boolean(codexConfig),
};
}
private logTurnStartSummary({
turnId,
thinkingOptionId,
approvalPolicy,
sandboxPolicyType,
hasOutputSchema,
hasCodexConfig,
}: {
turnId: string;
thinkingOptionId?: string;
approvalPolicy: string;
sandboxPolicyType: string;
hasOutputSchema: boolean;
hasCodexConfig: boolean;
}): void {
this.logger.info(
{
turnId,
threadId: this.currentThreadId,
model: this.config.model ?? null,
modeId: this.currentMode ?? null,
effort: thinkingOptionId ?? null,
serviceTier: this.serviceTier,
cwd: this.config.cwd ?? null,
approvalPolicy,
sandboxPolicyType,
hasCollaborationMode: Boolean(this.resolvedCollaborationMode),
hasOutputSchema,
hasDeveloperInstructions: Boolean(this.config.systemPrompt?.trim()),
hasCodexConfig,
},
"Starting Codex app-server turn",
);
}
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
return runProviderTurn({
prompt,
runOptions: options,
startTurn: (p, o) => this.startTurn(p, o),
subscribe: (callback) => this.subscribe(callback),
getSessionId: async () => (await this.getRuntimeInfo()).sessionId ?? "",
reduceFinalText: ({ current, item }) => {
if (item.type === "assistant_message") {
return item.text;
}
if (item.type === "tool_call" && item.detail.type === "plan") {
return item.detail.text;
}
return current;
},
});
}
async startTurn(
prompt: AgentPromptInput,
options?: AgentRunOptions,
): Promise<{ turnId: string }> {
if (this.activeForegroundTurnId) {
throw new Error("A foreground turn is already active");
}
await this.connect();
if (!this.client) {
throw new Error("Codex client not initialized");
}
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
const effectivePrompt = slashCommand
? await this.buildCommandPromptInput(slashCommand.commandName, slashCommand.args)
: prompt;
if (this.currentThreadId) {
await this.ensureThreadLoaded();
} else {
await this.ensureThread();
}
const turnStart = await this.buildTurnStartParams(effectivePrompt, options);
const turnId = this.createTurnId(); const turnId = this.createTurnId();
this.activeForegroundTurnId = turnId; this.activeForegroundTurnId = turnId;
try { try {
await this.client.request("turn/start", params, TURN_START_TIMEOUT_MS); this.logTurnStartSummary({
turnId,
thinkingOptionId: turnStart.thinkingOptionId,
approvalPolicy: turnStart.approvalPolicy,
sandboxPolicyType: turnStart.sandboxPolicyType,
hasOutputSchema: turnStart.hasOutputSchema,
hasCodexConfig: turnStart.hasCodexConfig,
});
await this.client.request("turn/start", turnStart.params, TURN_START_TIMEOUT_MS);
} catch (error) { } catch (error) {
this.activeForegroundTurnId = null; this.activeForegroundTurnId = null;
throw error; throw error;
@@ -3288,6 +3354,11 @@ class CodexAppServerAgentSession implements AgentSession {
async setFeature(featureId: string, value: unknown): Promise<void> { async setFeature(featureId: string, value: unknown): Promise<void> {
if (featureId === "fast_mode") { if (featureId === "fast_mode") {
if (Boolean(value) && !codexModelSupportsFastMode(this.config.model)) {
throw new Error(
`Codex fast mode is not available for model '${this.config.model ?? "default"}'`,
);
}
this.applyFeatureValue("fast_mode", Boolean(value)); this.applyFeatureValue("fast_mode", Boolean(value));
return; return;
} }

View File

@@ -6,6 +6,7 @@ import path from "node:path";
import type { import type {
AgentCapabilityFlags, AgentCapabilityFlags,
AgentClient, AgentClient,
AgentFeature,
AgentLaunchContext, AgentLaunchContext,
AgentMode, AgentMode,
AgentModelDefinition, AgentModelDefinition,
@@ -32,6 +33,8 @@ const TEST_CAPABILITIES: AgentCapabilityFlags = {
supportsToolInvocations: true, supportsToolInvocations: true,
}; };
const TEST_FEATURE_ID = "test_feature";
interface Deferred<T> { interface Deferred<T> {
promise: Promise<T>; promise: Promise<T>;
resolve: (value: T) => void; resolve: (value: T) => void;
@@ -325,6 +328,18 @@ class FakeAgentSession implements AgentSession {
return this.providerName; return this.providerName;
} }
get features(): AgentFeature[] {
return [
{
type: "toggle",
id: TEST_FEATURE_ID,
label: "Test feature",
description: "Deterministic provider feature used by MCP integration tests.",
value: this.config.featureValues?.[TEST_FEATURE_ID] === true,
},
];
}
private async appendHistoryEvent(event: AgentStreamEvent): Promise<void> { private async appendHistoryEvent(event: AgentStreamEvent): Promise<void> {
const folder = path.dirname(this.historyPath); const folder = path.dirname(this.historyPath);
await mkdir(folder, { recursive: true }); await mkdir(folder, { recursive: true });
@@ -786,6 +801,13 @@ class FakeAgentSession implements AgentSession {
this.config.modeId = modeId; this.config.modeId = modeId;
} }
async setFeature(featureId: string, value: unknown): Promise<void> {
this.config.featureValues = {
...this.config.featureValues,
[featureId]: value,
};
}
getPendingPermissions(): AgentPermissionRequest[] { getPendingPermissions(): AgentPermissionRequest[] {
return this.pendingPermissions; return this.pendingPermissions;
} }

View File

@@ -15,19 +15,20 @@ The MCP server itself is controlled by `daemon.mcp.enabled`. Existing agents may
### Agents ### Agents
| Tool | Function | | Tool | Function |
| -------------------- | ----------------------------------------------------------------------------------------------------- | | -------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `create_agent` | Create an agent tied to a working directory, optionally with an initial prompt or a new git worktree. | | `create_agent` | Create an agent tied to a working directory, optionally with an initial prompt, provider features, or a new git worktree. |
| `wait_for_agent` | Block until an agent requests permission or finishes its current run. | | `wait_for_agent` | Block until an agent requests permission or finishes its current run. |
| `send_agent_prompt` | Send a task to a running agent. | | `send_agent_prompt` | Send a task to a running agent. |
| `get_agent_status` | Return the latest snapshot for an agent. | | `get_agent_status` | Return the latest snapshot for an agent. |
| `list_agents` | List recent agents as compact metadata. | | `list_agents` | List recent agents as compact metadata. |
| `cancel_agent` | Abort an agent's current run but keep the agent alive. | | `cancel_agent` | Abort an agent's current run but keep the agent alive. |
| `archive_agent` | Soft-delete an agent and remove it from the active list. | | `archive_agent` | Soft-delete an agent and remove it from the active list. |
| `kill_agent` | Terminate an agent session permanently. | | `kill_agent` | Terminate an agent session permanently. |
| `update_agent` | Update an agent name or labels. | | `update_agent` | Update an agent name or labels. |
| `get_agent_activity` | Return recent agent timeline entries as a curated summary. | | `get_agent_activity` | Return recent agent timeline entries as a curated summary. |
| `set_agent_mode` | Switch an agent's session mode. | | `set_agent_mode` | Switch an agent's session mode. |
| `set_agent_feature` | Set a provider-specific feature on an existing agent, for example Codex `fast_mode`. |
### Terminals ### Terminals

View File

@@ -20,12 +20,16 @@ Returns `{ branchName, worktreePath }`. Pass `cwd` to target a specific repo.
## Agents ## Agents
**`create_agent`** — required: `title`, `provider` (`claude/opus`, `codex/gpt-5.4`, …), `initialPrompt`. Common: `cwd` (often a `worktreePath`), `background` (default `false` — blocks until completion or permission), `notifyOnFinish`. Returns `{ agentId, … }`. **`create_agent`** — required: `title`, `provider` (`claude/opus`, `codex/gpt-5.4`, …), `initialPrompt`. Common: `cwd` (often a `worktreePath`), `background` (default `false` — blocks until completion or permission), `notifyOnFinish`, `features`. Returns `{ agentId, … }`.
Provider features are provider-specific. For Codex fast mode, pass `features: { "fast_mode": true }` when creating the agent.
Compose: call `create_worktree` first, then `create_agent` with `cwd` set to the returned `worktreePath`. Compose: call `create_worktree` first, then `create_agent` with `cwd` set to the returned `worktreePath`.
**`send_agent_prompt`** — `{ agentId, prompt }`. Blocks by default; pass `background: true` to fire-and-forget. **`send_agent_prompt`** — `{ agentId, prompt }`. Blocks by default; pass `background: true` to fire-and-forget.
**`set_agent_feature`** — `{ agentId, featureId, value }`. Use for provider-specific toggles on an existing agent, for example `{ agentId, featureId: "fast_mode", value: true }` for Codex.
**`list_agents`** — filter by `cwd`, `statuses`, `sinceHours`, `includeArchived`. **`list_agents`** — filter by `cwd`, `statuses`, `sinceHours`, `includeArchived`.
**`archive_agent`** — `{ agentId }`. Interrupts if running, removes from active list. **`archive_agent`** — `{ agentId }`. Interrupts if running, removes from active list.