diff --git a/packages/server/src/server/agent/mcp-parity.e2e.test.ts b/packages/server/src/server/agent/mcp-parity.e2e.test.ts index 1df7abb34..b5b0bfa41 100644 --- a/packages/server/src/server/agent/mcp-parity.e2e.test.ts +++ b/packages/server/src/server/agent/mcp-parity.e2e.test.ts @@ -34,6 +34,17 @@ function recordArr(val: unknown): StructuredContent[] { 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 { 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 () => { let agentId: string | null = null; try { @@ -480,6 +545,7 @@ describe("Suite C: Schedule Tools", () => { prompt: "say hello", every: "5m", name: "Parity schedule list", + provider: "claude", }); scheduleId = str(created.id); @@ -527,6 +593,7 @@ describe("Suite C: Schedule Tools", () => { prompt: "say hello", every: "5m", name: "Parity inspect schedule", + provider: "claude", }); scheduleId = str(created.id); @@ -551,6 +618,7 @@ describe("Suite C: Schedule Tools", () => { prompt: "say hello", every: "5m", name: "Parity pause schedule", + provider: "claude", }); scheduleId = str(created.id); @@ -577,6 +645,7 @@ describe("Suite C: Schedule Tools", () => { prompt: "say hello", every: "5m", name: "Parity delete schedule", + provider: "claude", }); scheduleId = str(created.id); diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index bfb339f14..65cb49fa6 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -47,6 +47,20 @@ interface LooseStructuredContent { interface RegisteredMcpTool { 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<{ structuredContent: LooseStructuredContent; content?: Array<{ type: string; text?: string }>; @@ -64,12 +78,16 @@ function lookupTool( function registeredTool( server: Awaited>, name: string, -): RegisteredMcpTool { +): RegisteredMcpToolWithHandler { const tool = lookupTool(server, name); if (!tool) { 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: { @@ -96,6 +114,7 @@ function buildAgentManagerSpies() { waitForAgentEvent: vi.fn(), recordUserMessage: vi.fn(), setAgentMode: vi.fn(), + setAgentFeature: vi.fn().mockResolvedValue(undefined), setLabels: vi.fn().mockResolvedValue(undefined), setTitle: vi.fn().mockResolvedValue(undefined), archiveAgent: vi.fn().mockResolvedValue({ archivedAt: new Date().toISOString() }), @@ -467,6 +486,44 @@ describe("create_agent MCP tool", () => { ).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 () => { const { agentManager, agentStorage } = createTestDeps(); const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); @@ -1269,6 +1326,58 @@ describe("create_agent MCP tool", () => { 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 () => { const { agentManager, agentStorage, spies } = createTestDeps(); 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", () => { const logger = createTestLogger(); diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 283ee0501..b547c860d 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -489,6 +489,10 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom "Required provider/model pair, for example codex/gpt-5.4.", ), 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"), initialPrompt: z .string() @@ -531,6 +535,10 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom "Required provider/model pair, for example codex/gpt-5.4.", ), 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"), initialPrompt: z .string() @@ -630,6 +638,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom normalizedTitle: string | null; model: string | undefined; thinking: string | undefined; + features: Record | undefined; labels: Record | undefined; notifyOnFinish: boolean; resolvedCwd: string; @@ -699,6 +708,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom normalizedTitle: callerArgs.title.trim(), model: resolvedProviderModel.model, thinking: callerArgs.thinking, + features: callerArgs.features, labels: callerArgs.labels, notifyOnFinish: callerArgs.notifyOnFinish ?? false, resolvedCwd, @@ -772,6 +782,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom normalizedTitle: topLevelArgs.title.trim(), model: resolvedProviderModel.model, thinking: topLevelArgs.thinking, + features: topLevelArgs.features, labels: topLevelArgs.labels, notifyOnFinish: topLevelArgs.notifyOnFinish ?? false, resolvedCwd, @@ -815,6 +826,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom normalizedTitle, model, thinking, + features, labels, notifyOnFinish, resolvedCwd, @@ -836,6 +848,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom title: normalizedTitle ?? undefined, model, thinkingOptionId: thinking, + featureValues: features, }, 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( "wait_for_agent", { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts index c4bc8df6c..b4bb92400 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts @@ -1,3 +1,4 @@ +import pino from "pino"; import { describe, expect, test } from "vitest"; import type { AgentSession, AgentSessionConfig } from "../agent-sdk-types.js"; @@ -33,6 +34,25 @@ const TEST_COLLABORATION_MODES: CollaborationModeRecord[] = [ 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 { return { provider: CODEX_PROVIDER, @@ -43,7 +63,10 @@ function createConfig(overrides: Partial = {}): AgentSession }; } -function createSessionHarness(configOverrides: Partial = {}): { +function createSessionHarness( + configOverrides: Partial = {}, + options: { logger?: pino.Logger } = {}, +): { session: CodexFeaturesTestSession; appServer: FakeCodexAppServer; } { @@ -54,17 +77,20 @@ function createSessionHarness(configOverrides: Partial = {}) const session = new __codexAppServerInternals.CodexAppServerAgentSession( { ...config, provider: CODEX_PROVIDER }, null, - createTestLogger(), + options.logger ?? createTestLogger(), async () => appServer.child, ) as CodexFeaturesTestSession; return { session, appServer }; } -async function createConnectedSession(configOverrides: Partial = {}): Promise<{ +async function createConnectedSession( + configOverrides: Partial = {}, + options: { logger?: pino.Logger } = {}, +): Promise<{ session: CodexFeaturesTestSession; appServer: FakeCodexAppServer; }> { - const harness = createSessionHarness(configOverrides); + const harness = createSessionHarness(configOverrides, options); await harness.session.connect(); harness.appServer.assertNoErrors(); 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 () => { 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 () => { 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 () => { const { session, appServer } = await createConnectedSession(); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index cf0d5c6a1..7aee4a885 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -2714,7 +2714,7 @@ class CodexAppServerAgentSession implements AgentSession { this.currentMode = config.modeId; this.config = config; 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"; } if (this.config.featureValues?.plan_mode) { @@ -3109,50 +3109,18 @@ class CodexAppServerAgentSession implements AgentSession { return args ? `$${commandName} ${args}` : `$${commandName}`; } - async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise { - 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, + private async buildTurnStartParams( + prompt: CodexPromptInput, 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 input = await this.buildUserInput(effectivePrompt); + ): Promise<{ + params: Record; + thinkingOptionId?: string; + approvalPolicy: string; + sandboxPolicyType: string; + hasOutputSchema: boolean; + hasCodexConfig: boolean; + }> { + const input = await this.buildUserInput(prompt); const preset = MODE_PRESETS[this.currentMode] ?? MODE_PRESETS[DEFAULT_CODEX_MODE_ID]; const approvalPolicy = this.config.approvalPolicy ?? preset.approvalPolicy; const sandboxPolicyType = this.config.sandboxMode ?? preset.sandbox; @@ -3200,11 +3168,109 @@ class CodexAppServerAgentSession implements AgentSession { 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 { + 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(); this.activeForegroundTurnId = turnId; 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) { this.activeForegroundTurnId = null; throw error; @@ -3288,6 +3354,11 @@ class CodexAppServerAgentSession implements AgentSession { async setFeature(featureId: string, value: unknown): Promise { 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)); return; } diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index e9e149949..ac2068568 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -6,6 +6,7 @@ import path from "node:path"; import type { AgentCapabilityFlags, AgentClient, + AgentFeature, AgentLaunchContext, AgentMode, AgentModelDefinition, @@ -32,6 +33,8 @@ const TEST_CAPABILITIES: AgentCapabilityFlags = { supportsToolInvocations: true, }; +const TEST_FEATURE_ID = "test_feature"; + interface Deferred { promise: Promise; resolve: (value: T) => void; @@ -325,6 +328,18 @@ class FakeAgentSession implements AgentSession { 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 { const folder = path.dirname(this.historyPath); await mkdir(folder, { recursive: true }); @@ -786,6 +801,13 @@ class FakeAgentSession implements AgentSession { this.config.modeId = modeId; } + async setFeature(featureId: string, value: unknown): Promise { + this.config.featureValues = { + ...this.config.featureValues, + [featureId]: value, + }; + } + getPendingPermissions(): AgentPermissionRequest[] { return this.pendingPermissions; } diff --git a/public-docs/mcp.md b/public-docs/mcp.md index 43953f0eb..66da4520f 100644 --- a/public-docs/mcp.md +++ b/public-docs/mcp.md @@ -15,19 +15,20 @@ The MCP server itself is controlled by `daemon.mcp.enabled`. Existing agents may ### Agents -| Tool | Function | -| -------------------- | ----------------------------------------------------------------------------------------------------- | -| `create_agent` | Create an agent tied to a working directory, optionally with an initial prompt or a new git worktree. | -| `wait_for_agent` | Block until an agent requests permission or finishes its current run. | -| `send_agent_prompt` | Send a task to a running agent. | -| `get_agent_status` | Return the latest snapshot for an agent. | -| `list_agents` | List recent agents as compact metadata. | -| `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. | -| `kill_agent` | Terminate an agent session permanently. | -| `update_agent` | Update an agent name or labels. | -| `get_agent_activity` | Return recent agent timeline entries as a curated summary. | -| `set_agent_mode` | Switch an agent's session mode. | +| Tool | Function | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `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. | +| `send_agent_prompt` | Send a task to a running agent. | +| `get_agent_status` | Return the latest snapshot for an agent. | +| `list_agents` | List recent agents as compact metadata. | +| `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. | +| `kill_agent` | Terminate an agent session permanently. | +| `update_agent` | Update an agent name or labels. | +| `get_agent_activity` | Return recent agent timeline entries as a curated summary. | +| `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 diff --git a/skills/paseo/SKILL.md b/skills/paseo/SKILL.md index 05bddf574..47bba1539 100644 --- a/skills/paseo/SKILL.md +++ b/skills/paseo/SKILL.md @@ -20,12 +20,16 @@ Returns `{ branchName, worktreePath }`. Pass `cwd` to target a specific repo. ## 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`. **`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`. **`archive_agent`** — `{ agentId }`. Interrupts if running, removes from active list.