From 93e26b70dcab6af0b7efca608850cfb60cb54837 Mon Sep 17 00:00:00 2001 From: Martin Troiber Date: Sun, 5 Jul 2026 11:33:26 +0000 Subject: [PATCH 01/13] Fix Codex voice MCP routing on TCP daemons --- .../src/server/agent/agent-manager.test.ts | 35 ++++ .../src/server/agent/agent-sdk-types.ts | 7 + .../src/server/agent/runtime-mcp-config.ts | 7 + packages/server/src/server/bootstrap.ts | 18 +- .../server/session.voice-mcp-config.test.ts | 67 +++++++ .../session/voice/voice-session.test.ts | 99 +++++++++- .../src/server/session/voice/voice-session.ts | 100 +++++++++- packages/server/src/server/voice-config.ts | 182 ++++++++++++++++-- .../server/src/server/websocket-server.ts | 4 + 9 files changed, 489 insertions(+), 30 deletions(-) diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index fa1d77f5c1..bfd3e12e15 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -39,6 +39,7 @@ import type { } from "./agent-sdk-types.js"; import type { PaseoToolCatalog } from "./tools/types.js"; import type { ProviderDefinition } from "./provider-registry.js"; +import { PASEO_VOICE_MCP_SERVER_NAME } from "../voice-config.js"; interface Deferred { promise: Promise; @@ -3764,6 +3765,40 @@ test("fetchTimeline returns a bounded reset window when cursor epoch is stale", expect(older.hasOlder).toBe(true); }); +test("dedicated Codex voice MCP sessions skip generic paseo MCP injection", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-codex-voice-mcp-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const client = new TestAgentClient(); + const manager = new AgentManager({ + clients: { codex: client }, + registry: storage, + logger, + }); + manager.setMcpBaseUrl("http://127.0.0.1:4123/mcp/agents"); + + await manager.createAgent({ + provider: "codex", + cwd: workdir, + voiceToolMcpServerName: PASEO_VOICE_MCP_SERVER_NAME, + mcpServers: { + [PASEO_VOICE_MCP_SERVER_NAME]: { + type: "stdio", + command: "node", + args: ["bridge.mjs", "--socket", "/tmp/paseo.sock"], + }, + }, + }); + + expect(client.createdConfigs).toHaveLength(1); + expect(client.createdConfigs[0]?.mcpServers).toEqual({ + [PASEO_VOICE_MCP_SERVER_NAME]: { + type: "stdio", + command: "node", + args: ["bridge.mjs", "--socket", "/tmp/paseo.sock"], + }, + }); +}); + test("getTimelineRows falls back to the in-memory timeline when no durable store is configured", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-rows-fallback-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 6836cd8bc4..03ef492ff4 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -577,6 +577,13 @@ export interface AgentSessionConfig { claude?: Partial; }; mcpServers?: Record; + /** + * Internal voice-mode override for provider-specific speech tool routing. + * This is persisted with the session config so reloads can apply the same + * launch behavior until voice mode is disabled and the original config is + * restored. + */ + voiceToolMcpServerName?: string; /** * Internal agents are hidden from listings and don't trigger notifications. * They are used for ephemeral system tasks like commit/PR generation. diff --git a/packages/server/src/server/agent/runtime-mcp-config.ts b/packages/server/src/server/agent/runtime-mcp-config.ts index af04e8021c..49c0b46998 100644 --- a/packages/server/src/server/agent/runtime-mcp-config.ts +++ b/packages/server/src/server/agent/runtime-mcp-config.ts @@ -3,6 +3,10 @@ import type { AgentSessionConfig, McpServerConfig } from "./agent-sdk-types.js"; const PASEO_MCP_SERVER_NAME = "paseo"; const PASEO_MCP_PATHNAME = "/mcp/agents"; +function shouldSkipRuntimePaseoMcpServer(config: AgentSessionConfig): boolean { + return config.voiceToolMcpServerName === "paseo_voice"; +} + export function stripInternalPaseoMcpServer(config: AgentSessionConfig): AgentSessionConfig { const mcpServers = config.mcpServers; if (!mcpServers) { @@ -38,6 +42,9 @@ export function withRuntimePaseoMcpServer(params: { mcpAuthToken: string | null; }): AgentSessionConfig { const storedConfig = stripInternalPaseoMcpServer(params.config); + if (shouldSkipRuntimePaseoMcpServer(storedConfig)) { + return storedConfig; + } if (!params.mcpBaseUrl || storedConfig.mcpServers?.[PASEO_MCP_SERVER_NAME]) { return storedConfig; } diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 4d3d48929e..05368e7131 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1280,9 +1280,15 @@ export async function createPaseoDaemon( if (mcpEnabled) { const agentMcpRoute = "/mcp/agents"; - const createAgentMcpSession = async (callerAgentId?: string) => { + const createAgentMcpSession = async ( + callerAgentId?: string, + options?: { voiceOnly?: boolean }, + ) => { const agentMcpServer = await createAgentMcpServer( - createAgentToolHostDependencies({ callerAgentId }), + createAgentToolHostDependencies({ + callerAgentId, + ...(options?.voiceOnly ? { voiceOnly: true, enableVoiceTools: true } : {}), + }), ); // Stateless mode: each HTTP request builds a fresh server + transport that is @@ -1358,7 +1364,12 @@ export async function createPaseoDaemon( } else if (Array.isArray(callerAgentIdRaw) && typeof callerAgentIdRaw[0] === "string") { callerAgentId = callerAgentIdRaw[0]; } - const { server, transport } = await createAgentMcpSession(callerAgentId); + const voiceOnlyRaw = req.query.voiceOnly; + const voiceOnly = + voiceOnlyRaw === "1" || + voiceOnlyRaw === "true" || + (Array.isArray(voiceOnlyRaw) && (voiceOnlyRaw[0] === "1" || voiceOnlyRaw[0] === "true")); + const { server, transport } = await createAgentMcpSession(callerAgentId, { voiceOnly }); res.on("close", () => { void transport.close(); void server.close(); @@ -1486,6 +1497,7 @@ export async function createPaseoDaemon( config.paseoHome, daemonConfigStore, mcpBaseUrl, + agentMcpAuthToken, { allowedOrigins, hostnames: configuredHostnames }, workspaceAutoName, config.auth, diff --git a/packages/server/src/server/session.voice-mcp-config.test.ts b/packages/server/src/server/session.voice-mcp-config.test.ts index 98b1d41020..cf278d8f30 100644 --- a/packages/server/src/server/session.voice-mcp-config.test.ts +++ b/packages/server/src/server/session.voice-mcp-config.test.ts @@ -1,9 +1,13 @@ import { describe, expect, test } from "vitest"; import { + buildDedicatedVoiceAgentMcpServerConfig, buildVoiceAgentMcpServerConfig, buildVoiceModeSystemPrompt, + PASEO_VOICE_MCP_SERVER_NAME, + resolveVoiceAgentBridgeSocketPath, stripVoiceModeSystemPrompt, + wrapSpokenInput, } from "./voice-config.js"; describe("voice MCP stdio config", () => { @@ -43,6 +47,15 @@ describe("voice mode prompt instructions", () => { expect(prompt).toContain(""); }); + test("builds enabled voice instructions for a dedicated voice MCP server", () => { + const prompt = buildVoiceModeSystemPrompt("Base system prompt", true, { + voiceToolMcpServerName: PASEO_VOICE_MCP_SERVER_NAME, + }); + + expect(prompt).toContain(`Always use the ${PASEO_VOICE_MCP_SERVER_NAME}.speak tool`); + expect(prompt).toContain(`first call ${PASEO_VOICE_MCP_SERVER_NAME}.speak`); + }); + test("builds disabled voice instructions and supersedes previous voice block", () => { const existing = [ "Base system prompt", @@ -76,3 +89,57 @@ describe("voice mode prompt instructions", () => { ).toBeUndefined(); }); }); + +describe("spoken-input wrapping", () => { + test("defaults to the generic speak tool", () => { + expect(wrapSpokenInput("hello")).toContain("Respond using the speak tool only"); + }); + + test("mentions the dedicated voice MCP tool when configured", () => { + expect( + wrapSpokenInput("hello", { + voiceToolMcpServerName: PASEO_VOICE_MCP_SERVER_NAME, + }), + ).toContain(`Respond using the ${PASEO_VOICE_MCP_SERVER_NAME}.speak tool only`); + }); +}); + +describe("voice bridge resolution", () => { + test("resolves local socket and pipe listeners", () => { + expect(resolveVoiceAgentBridgeSocketPath("unix:///tmp/paseo.sock")).toBe("/tmp/paseo.sock"); + expect(resolveVoiceAgentBridgeSocketPath("/tmp/paseo.sock")).toBe("/tmp/paseo.sock"); + expect(resolveVoiceAgentBridgeSocketPath("pipe://voice-bridge")).toBe("voice-bridge"); + expect(resolveVoiceAgentBridgeSocketPath("127.0.0.1:1234")).toBeNull(); + }); + + test("builds a dedicated voice MCP bridge config for local listeners", () => { + const config = buildDedicatedVoiceAgentMcpServerConfig({ + listen: "/tmp/paseo.sock", + paseoHome: "/tmp/paseo-home", + }); + + expect(config).not.toBeNull(); + expect(config?.type).toBe("stdio"); + expect(config?.args.at(-2)).toBe("--socket"); + expect(config?.args.at(-1)).toBe("/tmp/paseo.sock"); + expect(config?.env?.PASEO_HOME).toBe("/tmp/paseo-home"); + }); + + test("builds a dedicated HTTP voice MCP config when an agent MCP base URL is available", () => { + const config = buildDedicatedVoiceAgentMcpServerConfig({ + mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", + callerAgentId: "00000000-0000-4000-8000-000000000001", + mcpAuthToken: "test-token", + listen: "127.0.0.1:6767", + paseoHome: "/tmp/paseo-home", + }); + + expect(config).toEqual({ + type: "http", + url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=00000000-0000-4000-8000-000000000001&voiceOnly=1", + headers: { + Authorization: "Bearer test-token", + }, + }); + }); +}); diff --git a/packages/server/src/server/session/voice/voice-session.test.ts b/packages/server/src/server/session/voice/voice-session.test.ts index d6232ff4d8..66522173e3 100644 --- a/packages/server/src/server/session/voice/voice-session.test.ts +++ b/packages/server/src/server/session/voice/voice-session.test.ts @@ -3,7 +3,9 @@ import pino from "pino"; import { describe, expect, test, vi } from "vitest"; import { VoiceSession, type VoiceSessionHost } from "./voice-session.js"; +import { createTestLogger } from "../../../test-utils/test-logger.js"; import type { ManagedAgent } from "../../agent/agent-manager.js"; +import type { AgentSessionConfig } from "../../agent/agent-sdk-types.js"; import type { SessionOutboundMessage } from "../../messages.js"; import type { SpeechToTextProvider, @@ -79,7 +81,7 @@ function createFakeHost(): FakeVoiceHost { }; } -function createVoiceSession() { +function createStreamingVoiceSession() { const detector = new FakeVoiceTurnDetectionSession(); const sttSession = new FakeVoiceSttSession(); const stt: SpeechToTextProvider = { @@ -99,10 +101,43 @@ function createVoiceSession() { tts: null, stt, voice: { turnDetection }, + paseoHome: "/tmp/paseo-home", + daemonRuntimeConfig: { + listen: "/tmp/paseo.sock", + relay: null, + }, }); return { voiceSession, detector, sttSession, host }; } +function createCodexOverrideVoiceSession() { + return new VoiceSession({ + host: { + emit: () => {}, + loadAgent: async () => { + throw new Error("loadAgent should not be called in this test"); + }, + reloadAgentSession: async () => { + throw new Error("reloadAgentSession should not be called in this test"); + }, + sendSpokenInput: async () => {}, + interruptAgentIfRunning: async () => {}, + hasActiveAgentRun: () => false, + }, + logger: createTestLogger("silent"), + sessionId: "voice-session-config-test", + tts: null, + stt: null, + paseoHome: "/tmp/paseo-home", + mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", + mcpAuthToken: "test-token", + daemonRuntimeConfig: { + listen: "127.0.0.1:6767", + relay: null, + }, + }); +} + async function settle(): Promise { await Promise.resolve(); await Promise.resolve(); @@ -137,7 +172,7 @@ describe("VoiceSession streaming transcription", () => { }); test("delivers the streaming final transcript to the agent exactly once", async () => { - const { voiceSession, detector, sttSession, host } = createVoiceSession(); + const { voiceSession, detector, sttSession, host } = createStreamingVoiceSession(); await voiceSession.handleSetVoiceMode(true, VOICE_AGENT_ID); detector.emit("speech_started"); @@ -176,7 +211,7 @@ describe("VoiceSession streaming transcription", () => { test("emits an empty transcript on finalization timeout without submitting to the agent", async () => { vi.useFakeTimers(); try { - const { voiceSession, detector, sttSession, host } = createVoiceSession(); + const { voiceSession, detector, sttSession, host } = createStreamingVoiceSession(); await voiceSession.handleSetVoiceMode(true, VOICE_AGENT_ID); detector.emit("speech_started"); @@ -203,7 +238,7 @@ describe("VoiceSession streaming transcription", () => { }); test("filters a low-confidence streaming final without submitting to the agent", async () => { - const { voiceSession, detector, sttSession, host } = createVoiceSession(); + const { voiceSession, detector, sttSession, host } = createStreamingVoiceSession(); await voiceSession.handleSetVoiceMode(true, VOICE_AGENT_ID); detector.emit("speech_started"); @@ -235,3 +270,59 @@ describe("VoiceSession streaming transcription", () => { await voiceSession.cleanup(); }); }); + +describe("VoiceSession Codex voice MCP overrides", () => { + test("builds dedicated Codex voice-mode overrides when an agent MCP base URL is available", () => { + const session = createCodexOverrideVoiceSession(); + + const overrides = ( + session as unknown as { + buildVoiceModeRefreshOverrides: ( + agentId: string, + existingConfig: AgentSessionConfig, + baseConfig: { systemPrompt?: string }, + ) => Partial; + } + ).buildVoiceModeRefreshOverrides( + "00000000-0000-4000-8000-000000000001", + { + provider: "codex", + cwd: "/tmp/project", + }, + { systemPrompt: "Base system prompt" }, + ); + + expect(overrides.voiceToolMcpServerName).toBe("paseo_voice"); + expect(overrides.systemPrompt).toContain("paseo_voice.speak"); + expect(overrides.mcpServers?.paseo_voice).toMatchObject({ + type: "http", + url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=00000000-0000-4000-8000-000000000001&voiceOnly=1", + }); + }); + + test("leaves non-Codex voice-mode overrides on the generic speak tool path", () => { + const session = createCodexOverrideVoiceSession(); + + const overrides = ( + session as unknown as { + buildVoiceModeRefreshOverrides: ( + agentId: string, + existingConfig: AgentSessionConfig, + baseConfig: { systemPrompt?: string }, + ) => Partial; + } + ).buildVoiceModeRefreshOverrides( + "00000000-0000-4000-8000-000000000001", + { + provider: "claude", + cwd: "/tmp/project", + }, + { systemPrompt: "Base system prompt" }, + ); + + expect(overrides.voiceToolMcpServerName).toBeUndefined(); + expect(overrides.systemPrompt).toContain("speak tool"); + expect(overrides.systemPrompt).not.toContain("paseo_voice.speak"); + expect(overrides.mcpServers).toBeUndefined(); + }); +}); diff --git a/packages/server/src/server/session/voice/voice-session.ts b/packages/server/src/server/session/voice/voice-session.ts index f6331dba3f..65dbed0981 100644 --- a/packages/server/src/server/session/voice/voice-session.ts +++ b/packages/server/src/server/session/voice/voice-session.ts @@ -14,13 +14,19 @@ import { type DictationStreamOutboundMessage, } from "../../dictation/dictation-stream-manager.js"; import { createVoiceTurnController, type VoiceTurnController } from "./voice-turn-controller.js"; -import { buildVoiceModeSystemPrompt, stripVoiceModeSystemPrompt } from "../../voice-config.js"; +import { + buildDedicatedVoiceAgentMcpServerConfig, + buildVoiceModeSystemPrompt, + PASEO_VOICE_MCP_SERVER_NAME, + stripVoiceModeSystemPrompt, +} from "../../voice-config.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "../../voice-types.js"; import type { ManagedAgent } from "../../agent/agent-manager.js"; -import type { AgentSessionConfig } from "../../agent/agent-sdk-types.js"; +import type { AgentSessionConfig, McpServerConfig } from "../../agent/agent-sdk-types.js"; import type { LocalSpeechModelId } from "../../speech/providers/local/models.js"; import { toResolver, type Resolvable } from "../../speech/provider-resolver.js"; import type { SpeechReadinessSnapshot, SpeechReadinessState } from "../../speech/speech-runtime.js"; +import type { DaemonRuntimeConfig } from "../daemon/daemon-session.js"; const PCM_SAMPLE_RATE = 16000; const PCM_CHANNELS = 1; @@ -36,6 +42,8 @@ type ProcessingPhase = "idle" | "transcribing"; interface VoiceModeBaseConfig { systemPrompt?: string; + mcpServers?: Record; + voiceToolMcpServerName?: string; } interface AudioBufferState { @@ -153,6 +161,10 @@ export interface VoiceSessionOptions { sttLanguage?: string; getSpeechReadiness?: () => SpeechReadinessSnapshot; }; + paseoHome: string; + mcpBaseUrl?: string | null; + mcpAuthToken?: string | null; + daemonRuntimeConfig?: DaemonRuntimeConfig; } /** @@ -203,13 +215,30 @@ export class VoiceSession { ) => void; private readonly unregisterVoiceCallerContext?: (agentId: string) => void; private readonly getSpeechReadiness?: () => SpeechReadinessSnapshot; + private readonly paseoHome: string; + private readonly mcpBaseUrl: string | null; + private readonly mcpAuthToken: string | null; + private readonly daemonRuntimeConfig: DaemonRuntimeConfig | undefined; private voiceModeAgentId: string | null = null; private voiceModeBaseConfig: VoiceModeBaseConfig | null = null; constructor(options: VoiceSessionOptions) { - const { host, logger, sessionId, sttLanguage, tts, stt, voice, voiceBridge, dictation } = - options; + const { + host, + logger, + sessionId, + sttLanguage, + tts, + stt, + voice, + voiceBridge, + dictation, + paseoHome, + mcpBaseUrl, + mcpAuthToken, + daemonRuntimeConfig, + } = options; this.host = host; this.sessionLogger = logger; this.sessionId = sessionId; @@ -222,6 +251,10 @@ export class VoiceSession { this.registerVoiceCallerContext = voiceBridge?.registerVoiceCallerContext; this.unregisterVoiceCallerContext = voiceBridge?.unregisterVoiceCallerContext; this.getSpeechReadiness = dictation?.getSpeechReadiness; + this.paseoHome = paseoHome; + this.mcpBaseUrl = mcpBaseUrl ?? null; + this.mcpAuthToken = mcpAuthToken ?? null; + this.daemonRuntimeConfig = daemonRuntimeConfig; this.ttsManager = new TTSManager(this.sessionId, this.sessionLogger, tts); this.sttManager = new STTManager(this.sessionId, this.sessionLogger, stt, { @@ -482,11 +515,15 @@ export class VoiceSession { const baseConfig: VoiceModeBaseConfig = { systemPrompt: stripVoiceModeSystemPrompt(existing.config.systemPrompt), + mcpServers: existing.config.mcpServers, + voiceToolMcpServerName: existing.config.voiceToolMcpServerName, }; this.voiceModeBaseConfig = baseConfig; - const refreshOverrides: Partial = { - systemPrompt: buildVoiceModeSystemPrompt(baseConfig.systemPrompt, true), - }; + const refreshOverrides = this.buildVoiceModeRefreshOverrides( + agentId, + existing.config, + baseConfig, + ); try { this.sessionLogger.info( @@ -507,6 +544,53 @@ export class VoiceSession { } } + private buildVoiceModeRefreshOverrides( + agentId: string, + existingConfig: AgentSessionConfig, + baseConfig: VoiceModeBaseConfig, + ): Partial { + const codexVoiceOverrides = this.buildCodexVoiceModeOverrides(agentId, existingConfig); + return { + systemPrompt: buildVoiceModeSystemPrompt(baseConfig.systemPrompt, true, { + voiceToolMcpServerName: + codexVoiceOverrides?.voiceToolMcpServerName ?? baseConfig.voiceToolMcpServerName, + }), + ...(codexVoiceOverrides ?? {}), + }; + } + + private buildCodexVoiceModeOverrides( + agentId: string, + existingConfig: AgentSessionConfig, + ): Partial | null { + if (existingConfig.provider !== "codex") { + return null; + } + + const voiceMcpServer = buildDedicatedVoiceAgentMcpServerConfig({ + mcpBaseUrl: this.mcpBaseUrl, + callerAgentId: agentId, + mcpAuthToken: this.mcpAuthToken, + listen: this.daemonRuntimeConfig?.listen, + paseoHome: this.paseoHome, + }); + if (!voiceMcpServer) { + this.sessionLogger.warn( + { provider: existingConfig.provider, listen: this.daemonRuntimeConfig?.listen ?? null }, + "Voice mode could not resolve a dedicated Codex voice MCP server; falling back to generic speak tool", + ); + return null; + } + + return { + mcpServers: { + ...(existingConfig.mcpServers ?? {}), + [PASEO_VOICE_MCP_SERVER_NAME]: voiceMcpServer, + }, + voiceToolMcpServerName: PASEO_VOICE_MCP_SERVER_NAME, + }; + } + private async disableVoiceModeForActiveAgent(restoreAgentConfig: boolean): Promise { await this.stopVoiceTurnController(); @@ -524,6 +608,8 @@ export class VoiceSession { try { await this.host.reloadAgentSession(agentId, { systemPrompt: buildVoiceModeSystemPrompt(baseConfig.systemPrompt, false), + mcpServers: baseConfig.mcpServers, + voiceToolMcpServerName: baseConfig.voiceToolMcpServerName, }); } catch (error) { this.sessionLogger.warn( diff --git a/packages/server/src/server/voice-config.ts b/packages/server/src/server/voice-config.ts index d8cb1992f4..f46c4ffc2a 100644 --- a/packages/server/src/server/voice-config.ts +++ b/packages/server/src/server/voice-config.ts @@ -1,18 +1,35 @@ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { buildSelfNodeCommand } from "./paseo-env.js"; + const VOICE_PROMPT_BLOCK_START = ""; const VOICE_PROMPT_BLOCK_END = ""; +const PASEO_SERVER_PACKAGE_NAME = "@getpaseo/server"; -const VOICE_AGENT_SYSTEM_INSTRUCTION = [ - "Paseo voice mode is now on.", - "You are the Paseo voice assistant.", - "The user cannot see your chat messages or tool calls.", - "Always use the speak tool for all user-facing communication.", - "Before calling any non-speak tool, first call speak with a short acknowledgement of what you heard and what you will do next.", - "For long-running work, use speak to provide progress updates before and during execution.", - "Treat the user input as transcribed speech.", - "If the user intent is clear, proceed without extra confirmation.", - "If the transcription seems incomplete, cut off, ambiguous, or may contain a non-obvious mistake or misspelling, ask a clarifying question via speak before taking action.", - "Use concise plain language suitable for speech output.", -].join(" "); +export const PASEO_VOICE_MCP_SERVER_NAME = "paseo_voice"; + +function resolveVoiceSpeakToolName(voiceToolMcpServerName?: string): string { + return voiceToolMcpServerName ? `${voiceToolMcpServerName}.speak` : "speak"; +} + +function buildEnabledVoiceAgentSystemInstruction(voiceToolMcpServerName?: string): string { + const speakToolName = resolveVoiceSpeakToolName(voiceToolMcpServerName); + return [ + "Paseo voice mode is now on.", + "You are the Paseo voice assistant.", + "The user cannot see your chat messages or tool calls.", + `Always use the ${speakToolName} tool for all user-facing communication.`, + `Before calling any non-speech tool, first call ${speakToolName} with a short acknowledgement of what you heard and what you will do next.`, + `For long-running work, use ${speakToolName} to provide progress updates before and during execution.`, + "Treat the user input as transcribed speech.", + "If the user intent is clear, proceed without extra confirmation.", + `If the transcription seems incomplete, cut off, ambiguous, or may contain a non-obvious mistake or misspelling, ask a clarifying question via ${speakToolName} before taking action.`, + "Use concise plain language suitable for speech output.", + ].join(" "); +} const VOICE_AGENT_DISABLED_INSTRUCTION = [ "Paseo voice mode is now off.", @@ -39,10 +56,14 @@ export function stripVoiceModeSystemPrompt(existing?: string): string | undefine return stripped.length > 0 ? stripped : undefined; } -export function buildVoiceModeSystemPrompt(existing: string | undefined, enabled: boolean): string { +export function buildVoiceModeSystemPrompt( + existing: string | undefined, + enabled: boolean, + options?: { voiceToolMcpServerName?: string }, +): string { const basePrompt = stripVoiceModeSystemPrompt(existing); const voiceInstruction = enabled - ? VOICE_AGENT_SYSTEM_INSTRUCTION + ? buildEnabledVoiceAgentSystemInstruction(options?.voiceToolMcpServerName) : VOICE_AGENT_DISABLED_INSTRUCTION; const voiceBlock = [VOICE_PROMPT_BLOCK_START, voiceInstruction, VOICE_PROMPT_BLOCK_END].join( "\n", @@ -53,8 +74,12 @@ export function buildVoiceModeSystemPrompt(existing: string | undefined, enabled .join("\n\n"); } -export function wrapSpokenInput(text: string): string { - return `\n${text}\n\nThis message was spoken by the user. Respond using the speak tool only, not normal messages, because the user may not be looking at the chat.`; +export function wrapSpokenInput( + text: string, + options?: { voiceToolMcpServerName?: string }, +): string { + const speakToolName = resolveVoiceSpeakToolName(options?.voiceToolMcpServerName); + return `\n${text}\n\nThis message was spoken by the user. Respond using the ${speakToolName} tool only, not normal messages, because the user may not be looking at the chat.`; } export function buildVoiceAgentMcpServerConfig(params: { @@ -75,3 +100,128 @@ export function buildVoiceAgentMcpServerConfig(params: { ...(params.env ? { env: params.env } : {}), }; } + +export function resolveVoiceAgentBridgeSocketPath( + listen: string | null | undefined, +): string | null { + const trimmed = listen?.trim(); + if (!trimmed) { + return null; + } + if (trimmed.startsWith("\\\\.\\pipe\\")) { + return trimmed; + } + if (trimmed.startsWith("pipe://")) { + return trimmed.slice("pipe://".length); + } + if (trimmed.startsWith("unix://")) { + return trimmed.slice("unix://".length); + } + if (trimmed.startsWith("~/")) { + return path.join(homedir(), trimmed.slice(2)); + } + if (trimmed === "~") { + return homedir(); + } + if (trimmed.startsWith("/")) { + return trimmed; + } + return null; +} + +export function resolveVoiceAgentBridgeScriptPath( + moduleUrl: string = import.meta.url, +): string | null { + const packageRoot = resolvePackageRootFrom(fileURLToPath(moduleUrl), PASEO_SERVER_PACKAGE_NAME); + if (!packageRoot) { + return null; + } + + const bundledPath = path.join(packageRoot, "dist", "scripts", "mcp-stdio-socket-bridge-cli.mjs"); + if (existsSync(bundledPath)) { + return bundledPath; + } + + const sourcePath = path.join(packageRoot, "scripts", "mcp-stdio-socket-bridge-cli.mjs"); + return existsSync(sourcePath) ? sourcePath : null; +} + +export function buildDedicatedVoiceAgentMcpServerConfig(params: { + mcpBaseUrl?: string | null; + callerAgentId?: string | null; + mcpAuthToken?: string | null; + listen: string | null | undefined; + paseoHome: string; + moduleUrl?: string; +}): + | { + type: "http"; + url: string; + headers?: Record; + } + | { + type: "stdio"; + command: string; + args: string[]; + env?: Record; + } + | null { + const callerAgentId = params.callerAgentId?.trim(); + if (params.mcpBaseUrl && callerAgentId) { + const url = new URL(params.mcpBaseUrl); + url.searchParams.set("callerAgentId", callerAgentId); + url.searchParams.set("voiceOnly", "1"); + return { + type: "http", + url: url.toString(), + ...(params.mcpAuthToken + ? { headers: { Authorization: `Bearer ${params.mcpAuthToken}` } } + : {}), + }; + } + + const socketPath = resolveVoiceAgentBridgeSocketPath(params.listen); + if (!socketPath) { + return null; + } + + const bridgeScriptPath = resolveVoiceAgentBridgeScriptPath(params.moduleUrl); + if (!bridgeScriptPath) { + return null; + } + + const selfCommand = buildSelfNodeCommand([bridgeScriptPath], { + PASEO_HOME: params.paseoHome, + }); + + return buildVoiceAgentMcpServerConfig({ + command: selfCommand.command, + baseArgs: selfCommand.args, + socketPath, + env: selfCommand.env, + }); +} + +function resolvePackageRootFrom(startPath: string, packageName: string): string | null { + let currentDir = path.dirname(startPath); + + while (true) { + const packageJsonPath = path.join(currentDir, "package.json"); + if (existsSync(packageJsonPath)) { + try { + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { name?: unknown }; + if (packageJson.name === packageName) { + return currentDir; + } + } catch { + return null; + } + } + + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + return null; + } + currentDir = parentDir; + } +} diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index cec656af2a..0757065bcf 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -472,6 +472,7 @@ export class VoiceAssistantWebSocketServer { private readonly pushTokenStore: PushTokenStore; private readonly pushNotificationSender: PushNotificationSender; private readonly mcpBaseUrl: string | null; + private readonly mcpAuthToken: string | null; private speech!: SpeechService | null; private terminalManager!: TerminalManager | null; private serviceProxy!: ServiceProxySubsystem | null; @@ -515,6 +516,7 @@ export class VoiceAssistantWebSocketServer { paseoHome: string, daemonConfigStore: DaemonConfigStore, mcpBaseUrl: string | null, + mcpAuthToken: string | null, wsConfig: WebSocketServerConfig, workspaceAutoName: WorkspaceAutoName, auth?: DaemonAuthConfig, @@ -581,6 +583,7 @@ export class VoiceAssistantWebSocketServer { this.worktreesRoot = daemonRuntimeConfig?.worktreesRoot; this.daemonConfigStore = daemonConfigStore; this.mcpBaseUrl = mcpBaseUrl; + this.mcpAuthToken = mcpAuthToken; this.assignOptionalServices({ speech, terminalManager, @@ -1173,6 +1176,7 @@ export class VoiceAssistantWebSocketServer { workspaceAutoName: this.workspaceAutoName, daemonConfigStore: this.daemonConfigStore, mcpBaseUrl: this.mcpBaseUrl, + mcpAuthToken: this.mcpAuthToken, stt: () => this.speech?.resolveStt() ?? null, sttLanguage: this.speech?.resolveSttLanguage() ?? "en", tts: () => this.speech?.resolveTts() ?? null, From f7158e99f0a6dd653522a6027a56c1b13ffa421a Mon Sep 17 00:00:00 2001 From: Martin Troiber Date: Sun, 5 Jul 2026 12:30:36 +0000 Subject: [PATCH 02/13] Forward Codex MCP approval config for voice speak --- docs/codex-voice-mcp-approval.md | 37 +++++++++++++++++++ .../src/server/agent/agent-sdk-types.ts | 27 ++++++++++++++ .../providers/codex-app-server-agent.test.ts | 30 +++++++++++++++ .../agent/providers/codex-app-server-agent.ts | 30 +++++++++++++++ .../server/session.voice-mcp-config.test.ts | 10 +++++ .../session/voice/voice-session.test.ts | 5 +++ packages/server/src/server/voice-config.ts | 28 ++++++++------ 7 files changed, 155 insertions(+), 12 deletions(-) create mode 100644 docs/codex-voice-mcp-approval.md diff --git a/docs/codex-voice-mcp-approval.md b/docs/codex-voice-mcp-approval.md new file mode 100644 index 0000000000..e903872a11 --- /dev/null +++ b/docs/codex-voice-mcp-approval.md @@ -0,0 +1,37 @@ +# Codex Voice MCP Approval + +## Problem + +Codex voice-mode sessions were receiving the injected `paseo_voice` MCP server, but Paseo was not forwarding Codex-specific MCP approval settings into the app-server config. As a result, `paseo_voice.speak` fell back to Codex's default MCP approval behavior and could be rejected before Paseo's voice auto-allow hook ever ran. + +## Fix + +Paseo now carries MCP approval metadata in its shared agent config and forwards it to Codex as: + +- `mcp_servers..enabled_tools` +- `mcp_servers..default_tools_approval_mode` +- `mcp_servers..tools..approval_mode` + +For Codex voice mode, the injected `paseo_voice` server is now configured with: + +- `enabled_tools = ["speak"]` +- `default_tools_approval_mode = "prompt"` +- `tools.speak.approval_mode = "approve"` + +This keeps the dedicated voice server limited to `speak` while explicitly auto-approving that tool. + +## Verification + +1. Build or restart the Paseo server from this branch. +2. Start a fresh Codex voice conversation. +3. Speak a short prompt. +4. Confirm the response uses `mcp__paseo_voice.speak` and plays without a manual approval prompt. + +Focused tests: + +- `./node_modules/.bin/vitest run packages/server/src/server/session.voice-mcp-config.test.ts packages/server/src/server/session/voice/voice-session.test.ts` +- `./node_modules/.bin/vitest run packages/server/src/server/agent/providers/codex-app-server-agent.test.ts -t "maps MCP approval settings into Codex inner config"` + +## Revert + +Revert the commit that introduced this change, then restart the daemon so Codex sessions pick up the reverted MCP config mapping. diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 03ef492ff4..1b43ccd30d 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -24,6 +24,15 @@ export interface McpStdioServerConfig { * and never deferred behind tool search. Honored by the Claude provider. */ alwaysLoad?: boolean; + enabledTools?: string[]; + disabledTools?: string[]; + defaultToolsApprovalMode?: "auto" | "prompt" | "approve"; + tools?: Record< + string, + { + approvalMode?: "auto" | "prompt" | "approve"; + } + >; } /** @@ -38,6 +47,15 @@ export interface McpHttpServerConfig { * and never deferred behind tool search. Honored by the Claude provider. */ alwaysLoad?: boolean; + enabledTools?: string[]; + disabledTools?: string[]; + defaultToolsApprovalMode?: "auto" | "prompt" | "approve"; + tools?: Record< + string, + { + approvalMode?: "auto" | "prompt" | "approve"; + } + >; } /** @@ -52,6 +70,15 @@ export interface McpSseServerConfig { * and never deferred behind tool search. Honored by the Claude provider. */ alwaysLoad?: boolean; + enabledTools?: string[]; + disabledTools?: string[]; + defaultToolsApprovalMode?: "auto" | "prompt" | "approve"; + tools?: Record< + string, + { + approvalMode?: "auto" | "prompt" | "approve"; + } + >; } /** diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index a9df432f46..851c4f6943 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -48,6 +48,7 @@ interface CollaborationModeRecord { } interface CodexSessionTestAccess { + buildCodexInnerConfig(): Record | null; ensureThreadLoaded(): Promise; handleToolApprovalRequest(params: unknown): Promise; handleNotification(method: string, params: unknown): void; @@ -1079,6 +1080,35 @@ describe("Codex app-server provider", () => { }); }); + test("maps MCP approval settings into Codex inner config", () => { + const session = createSession({ + mcpServers: { + paseo_voice: { + type: "http", + url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=test&voiceOnly=1", + enabledTools: ["speak"], + defaultToolsApprovalMode: "prompt", + tools: { + speak: { approvalMode: "approve" }, + }, + }, + }, + }); + + expect(asInternals(session).buildCodexInnerConfig()).toEqual({ + mcp_servers: { + paseo_voice: { + url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=test&voiceOnly=1", + enabled_tools: ["speak"], + default_tools_approval_mode: "prompt", + tools: { + speak: { approval_mode: "approve" }, + }, + }, + }, + }); + }); + test("resumeSession does not replace a persisted Codex thread when app-server resume fails", async () => { const threadRequests: string[] = []; const appServer = createFakeCodexAppServer({ 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 ac3017dac2..ed26204cff 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 @@ -787,23 +787,53 @@ interface CodexMcpServerConfig { args?: string[]; env?: Record; tool_timeout_sec?: number; + enabled_tools?: string[]; + disabled_tools?: string[]; + default_tools_approval_mode?: "auto" | "prompt" | "approve"; + tools?: Record< + string, + { + approval_mode?: "auto" | "prompt" | "approve"; + } + >; } function toCodexMcpConfig(config: McpServerConfig): CodexMcpServerConfig { + const base = { + ...(config.enabledTools ? { enabled_tools: config.enabledTools } : {}), + ...(config.disabledTools ? { disabled_tools: config.disabledTools } : {}), + ...(config.defaultToolsApprovalMode + ? { default_tools_approval_mode: config.defaultToolsApprovalMode } + : {}), + ...(config.tools + ? { + tools: Object.fromEntries( + Object.entries(config.tools).map(([toolName, toolConfig]) => [ + toolName, + toolConfig.approvalMode ? { approval_mode: toolConfig.approvalMode } : {}, + ]), + ), + } + : {}), + } satisfies Partial; + switch (config.type) { case "stdio": return { + ...base, command: config.command, args: config.args, env: config.env, }; case "http": return { + ...base, url: config.url, http_headers: config.headers, }; case "sse": return { + ...base, url: config.url, http_headers: config.headers, }; diff --git a/packages/server/src/server/session.voice-mcp-config.test.ts b/packages/server/src/server/session.voice-mcp-config.test.ts index cf278d8f30..f50eb3fce5 100644 --- a/packages/server/src/server/session.voice-mcp-config.test.ts +++ b/packages/server/src/server/session.voice-mcp-config.test.ts @@ -33,6 +33,11 @@ describe("voice MCP stdio config", () => { ELECTRON_RUN_AS_NODE: "1", PASEO_HOME: "/tmp/paseo-home", }); + expect(config.enabledTools).toEqual(["speak"]); + expect(config.defaultToolsApprovalMode).toBe("prompt"); + expect(config.tools).toEqual({ + speak: { approvalMode: "approve" }, + }); }); }); @@ -137,6 +142,11 @@ describe("voice bridge resolution", () => { expect(config).toEqual({ type: "http", url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=00000000-0000-4000-8000-000000000001&voiceOnly=1", + enabledTools: ["speak"], + defaultToolsApprovalMode: "prompt", + tools: { + speak: { approvalMode: "approve" }, + }, headers: { Authorization: "Bearer test-token", }, diff --git a/packages/server/src/server/session/voice/voice-session.test.ts b/packages/server/src/server/session/voice/voice-session.test.ts index 66522173e3..7f7b1aa4dc 100644 --- a/packages/server/src/server/session/voice/voice-session.test.ts +++ b/packages/server/src/server/session/voice/voice-session.test.ts @@ -297,6 +297,11 @@ describe("VoiceSession Codex voice MCP overrides", () => { expect(overrides.mcpServers?.paseo_voice).toMatchObject({ type: "http", url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=00000000-0000-4000-8000-000000000001&voiceOnly=1", + enabledTools: ["speak"], + defaultToolsApprovalMode: "prompt", + tools: { + speak: { approvalMode: "approve" }, + }, }); }); diff --git a/packages/server/src/server/voice-config.ts b/packages/server/src/server/voice-config.ts index f46c4ffc2a..1e2b353f00 100644 --- a/packages/server/src/server/voice-config.ts +++ b/packages/server/src/server/voice-config.ts @@ -10,6 +10,7 @@ const VOICE_PROMPT_BLOCK_END = ""; const PASEO_SERVER_PACKAGE_NAME = "@getpaseo/server"; export const PASEO_VOICE_MCP_SERVER_NAME = "paseo_voice"; +const PASEO_VOICE_ENABLED_TOOLS = ["speak"] as const; function resolveVoiceSpeakToolName(voiceToolMcpServerName?: string): string { return voiceToolMcpServerName ? `${voiceToolMcpServerName}.speak` : "speak"; @@ -87,16 +88,16 @@ export function buildVoiceAgentMcpServerConfig(params: { baseArgs: string[]; socketPath: string; env?: Record; -}): { - type: "stdio"; - command: string; - args: string[]; - env?: Record; -} { +}): import("./agent/agent-sdk-types.js").McpStdioServerConfig { return { type: "stdio", command: params.command, args: [...params.baseArgs, "--socket", params.socketPath], + enabledTools: [...PASEO_VOICE_ENABLED_TOOLS], + defaultToolsApprovalMode: "prompt", + tools: { + speak: { approvalMode: "approve" }, + }, ...(params.env ? { env: params.env } : {}), }; } @@ -158,13 +159,11 @@ export function buildDedicatedVoiceAgentMcpServerConfig(params: { type: "http"; url: string; headers?: Record; + enabledTools?: string[]; + defaultToolsApprovalMode?: "auto" | "prompt" | "approve"; + tools?: Record; } - | { - type: "stdio"; - command: string; - args: string[]; - env?: Record; - } + | import("./agent/agent-sdk-types.js").McpStdioServerConfig | null { const callerAgentId = params.callerAgentId?.trim(); if (params.mcpBaseUrl && callerAgentId) { @@ -174,6 +173,11 @@ export function buildDedicatedVoiceAgentMcpServerConfig(params: { return { type: "http", url: url.toString(), + enabledTools: [...PASEO_VOICE_ENABLED_TOOLS], + defaultToolsApprovalMode: "prompt", + tools: { + speak: { approvalMode: "approve" }, + }, ...(params.mcpAuthToken ? { headers: { Authorization: `Bearer ${params.mcpAuthToken}` } } : {}), From e32a6a85feec02abb218b7f2b56c332fea7af776 Mon Sep 17 00:00:00 2001 From: Martin Troiber Date: Wed, 8 Jul 2026 07:21:29 +0000 Subject: [PATCH 03/13] Simplify Codex voice speak approval routing --- docs/codex-voice-mcp-approval.md | 8 +- .../src/server/agent/agent-manager.test.ts | 31 ++-- .../providers/codex-app-server-agent.test.ts | 8 +- .../server/agent/runtime-mcp-config.test.ts | 23 +++ .../src/server/agent/runtime-mcp-config.ts | 18 +- packages/server/src/server/bootstrap.ts | 18 +- .../server/session.voice-mcp-config.test.ts | 95 +---------- .../session/voice/voice-session.test.ts | 16 +- .../src/server/session/voice/voice-session.ts | 71 +------- packages/server/src/server/voice-config.ts | 159 +----------------- .../server/src/server/websocket-server.ts | 4 - 11 files changed, 82 insertions(+), 369 deletions(-) diff --git a/docs/codex-voice-mcp-approval.md b/docs/codex-voice-mcp-approval.md index e903872a11..48dd317c42 100644 --- a/docs/codex-voice-mcp-approval.md +++ b/docs/codex-voice-mcp-approval.md @@ -2,7 +2,7 @@ ## Problem -Codex voice-mode sessions were receiving the injected `paseo_voice` MCP server, but Paseo was not forwarding Codex-specific MCP approval settings into the app-server config. As a result, `paseo_voice.speak` fell back to Codex's default MCP approval behavior and could be rejected before Paseo's voice auto-allow hook ever ran. +Codex voice-mode sessions were receiving the injected `paseo` MCP server, but Paseo was not forwarding Codex-specific MCP approval settings into the app-server config. As a result, `paseo.speak` fell back to Codex's default MCP approval behavior and could be rejected before Paseo's voice auto-allow hook ever ran. ## Fix @@ -12,20 +12,20 @@ Paseo now carries MCP approval metadata in its shared agent config and forwards - `mcp_servers..default_tools_approval_mode` - `mcp_servers..tools..approval_mode` -For Codex voice mode, the injected `paseo_voice` server is now configured with: +For Codex voice mode, the injected `paseo` server is now configured with: - `enabled_tools = ["speak"]` - `default_tools_approval_mode = "prompt"` - `tools.speak.approval_mode = "approve"` -This keeps the dedicated voice server limited to `speak` while explicitly auto-approving that tool. +This keeps the approval override limited to `speak` while leaving the normal generic `paseo` MCP server in place. ## Verification 1. Build or restart the Paseo server from this branch. 2. Start a fresh Codex voice conversation. 3. Speak a short prompt. -4. Confirm the response uses `mcp__paseo_voice.speak` and plays without a manual approval prompt. +4. Confirm the response uses `mcp__paseo.speak` and plays without a manual approval prompt. Focused tests: diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index bfd3e12e15..59d8387da4 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -39,7 +39,7 @@ import type { } from "./agent-sdk-types.js"; import type { PaseoToolCatalog } from "./tools/types.js"; import type { ProviderDefinition } from "./provider-registry.js"; -import { PASEO_VOICE_MCP_SERVER_NAME } from "../voice-config.js"; +import { PASEO_MCP_SERVER_NAME } from "../voice-config.js"; interface Deferred { promise: Promise; @@ -3765,7 +3765,7 @@ test("fetchTimeline returns a bounded reset window when cursor epoch is stale", expect(older.hasOlder).toBe(true); }); -test("dedicated Codex voice MCP sessions skip generic paseo MCP injection", async () => { +test("Codex voice MCP sessions use the generic paseo MCP injection with voice approval", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-codex-voice-mcp-")); const storage = new AgentStorage(join(workdir, "agents"), logger); const client = new TestAgentClient(); @@ -3779,24 +3779,25 @@ test("dedicated Codex voice MCP sessions skip generic paseo MCP injection", asyn await manager.createAgent({ provider: "codex", cwd: workdir, - voiceToolMcpServerName: PASEO_VOICE_MCP_SERVER_NAME, - mcpServers: { - [PASEO_VOICE_MCP_SERVER_NAME]: { - type: "stdio", - command: "node", - args: ["bridge.mjs", "--socket", "/tmp/paseo.sock"], - }, - }, + voiceToolMcpServerName: PASEO_MCP_SERVER_NAME, }); expect(client.createdConfigs).toHaveLength(1); - expect(client.createdConfigs[0]?.mcpServers).toEqual({ - [PASEO_VOICE_MCP_SERVER_NAME]: { - type: "stdio", - command: "node", - args: ["bridge.mjs", "--socket", "/tmp/paseo.sock"], + expect(client.createdConfigs[0]?.mcpServers).toMatchObject({ + [PASEO_MCP_SERVER_NAME]: { + type: "http", + enabledTools: ["speak"], + defaultToolsApprovalMode: "prompt", + tools: { + speak: { approvalMode: "approve" }, + }, }, }); + expect(client.createdConfigs[0]?.mcpServers?.[PASEO_MCP_SERVER_NAME]?.type).toBe("http"); + expect(client.createdConfigs[0]?.mcpServers?.[PASEO_MCP_SERVER_NAME]).toHaveProperty( + "url", + expect.stringMatching(/^http:\/\/127\.0\.0\.1:4123\/mcp\/agents\?callerAgentId=[0-9a-f-]+$/), + ); }); test("getTimelineRows falls back to the in-memory timeline when no durable store is configured", async () => { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 851c4f6943..b11f3c0d26 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -1083,9 +1083,9 @@ describe("Codex app-server provider", () => { test("maps MCP approval settings into Codex inner config", () => { const session = createSession({ mcpServers: { - paseo_voice: { + paseo: { type: "http", - url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=test&voiceOnly=1", + url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=test", enabledTools: ["speak"], defaultToolsApprovalMode: "prompt", tools: { @@ -1097,8 +1097,8 @@ describe("Codex app-server provider", () => { expect(asInternals(session).buildCodexInnerConfig()).toEqual({ mcp_servers: { - paseo_voice: { - url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=test&voiceOnly=1", + paseo: { + url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=test", enabled_tools: ["speak"], default_tools_approval_mode: "prompt", tools: { diff --git a/packages/server/src/server/agent/runtime-mcp-config.test.ts b/packages/server/src/server/agent/runtime-mcp-config.test.ts index 4704d131bb..605e7f27a5 100644 --- a/packages/server/src/server/agent/runtime-mcp-config.test.ts +++ b/packages/server/src/server/agent/runtime-mcp-config.test.ts @@ -48,4 +48,27 @@ describe("withRuntimePaseoMcpServer", () => { expect(result.mcpServers).toBeUndefined(); }); + + test("adds voice approval metadata for Codex voice sessions on the generic paseo server", () => { + const result = withRuntimePaseoMcpServer({ + config: { + provider: "codex", + cwd: "/tmp/agent", + voiceToolMcpServerName: "paseo", + }, + agentId: "agent-1", + mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", + mcpAuthToken: null, + }); + + expect(result.mcpServers?.paseo).toEqual({ + type: "http", + url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1", + enabledTools: ["speak"], + defaultToolsApprovalMode: "prompt", + tools: { + speak: { approvalMode: "approve" }, + }, + }); + }); }); diff --git a/packages/server/src/server/agent/runtime-mcp-config.ts b/packages/server/src/server/agent/runtime-mcp-config.ts index 49c0b46998..38dfdc9305 100644 --- a/packages/server/src/server/agent/runtime-mcp-config.ts +++ b/packages/server/src/server/agent/runtime-mcp-config.ts @@ -2,10 +2,7 @@ import type { AgentSessionConfig, McpServerConfig } from "./agent-sdk-types.js"; const PASEO_MCP_SERVER_NAME = "paseo"; const PASEO_MCP_PATHNAME = "/mcp/agents"; - -function shouldSkipRuntimePaseoMcpServer(config: AgentSessionConfig): boolean { - return config.voiceToolMcpServerName === "paseo_voice"; -} +const PASEO_VOICE_ENABLED_TOOLS = ["speak"] as const; export function stripInternalPaseoMcpServer(config: AgentSessionConfig): AgentSessionConfig { const mcpServers = config.mcpServers; @@ -42,19 +39,26 @@ export function withRuntimePaseoMcpServer(params: { mcpAuthToken: string | null; }): AgentSessionConfig { const storedConfig = stripInternalPaseoMcpServer(params.config); - if (shouldSkipRuntimePaseoMcpServer(storedConfig)) { - return storedConfig; - } if (!params.mcpBaseUrl || storedConfig.mcpServers?.[PASEO_MCP_SERVER_NAME]) { return storedConfig; } + const applyVoiceApproval = storedConfig.voiceToolMcpServerName === PASEO_MCP_SERVER_NAME; return { ...storedConfig, mcpServers: { [PASEO_MCP_SERVER_NAME]: { type: "http", url: `${params.mcpBaseUrl}?callerAgentId=${params.agentId}`, + ...(applyVoiceApproval + ? { + enabledTools: [...PASEO_VOICE_ENABLED_TOOLS], + defaultToolsApprovalMode: "prompt" as const, + tools: { + speak: { approvalMode: "approve" as const }, + }, + } + : {}), ...(params.mcpAuthToken ? { headers: { Authorization: `Bearer ${params.mcpAuthToken}` } } : {}), diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 05368e7131..4d3d48929e 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1280,15 +1280,9 @@ export async function createPaseoDaemon( if (mcpEnabled) { const agentMcpRoute = "/mcp/agents"; - const createAgentMcpSession = async ( - callerAgentId?: string, - options?: { voiceOnly?: boolean }, - ) => { + const createAgentMcpSession = async (callerAgentId?: string) => { const agentMcpServer = await createAgentMcpServer( - createAgentToolHostDependencies({ - callerAgentId, - ...(options?.voiceOnly ? { voiceOnly: true, enableVoiceTools: true } : {}), - }), + createAgentToolHostDependencies({ callerAgentId }), ); // Stateless mode: each HTTP request builds a fresh server + transport that is @@ -1364,12 +1358,7 @@ export async function createPaseoDaemon( } else if (Array.isArray(callerAgentIdRaw) && typeof callerAgentIdRaw[0] === "string") { callerAgentId = callerAgentIdRaw[0]; } - const voiceOnlyRaw = req.query.voiceOnly; - const voiceOnly = - voiceOnlyRaw === "1" || - voiceOnlyRaw === "true" || - (Array.isArray(voiceOnlyRaw) && (voiceOnlyRaw[0] === "1" || voiceOnlyRaw[0] === "true")); - const { server, transport } = await createAgentMcpSession(callerAgentId, { voiceOnly }); + const { server, transport } = await createAgentMcpSession(callerAgentId); res.on("close", () => { void transport.close(); void server.close(); @@ -1497,7 +1486,6 @@ export async function createPaseoDaemon( config.paseoHome, daemonConfigStore, mcpBaseUrl, - agentMcpAuthToken, { allowedOrigins, hostnames: configuredHostnames }, workspaceAutoName, config.auth, diff --git a/packages/server/src/server/session.voice-mcp-config.test.ts b/packages/server/src/server/session.voice-mcp-config.test.ts index f50eb3fce5..23dc7f4be5 100644 --- a/packages/server/src/server/session.voice-mcp-config.test.ts +++ b/packages/server/src/server/session.voice-mcp-config.test.ts @@ -1,46 +1,12 @@ import { describe, expect, test } from "vitest"; import { - buildDedicatedVoiceAgentMcpServerConfig, - buildVoiceAgentMcpServerConfig, buildVoiceModeSystemPrompt, - PASEO_VOICE_MCP_SERVER_NAME, - resolveVoiceAgentBridgeSocketPath, + PASEO_MCP_SERVER_NAME, stripVoiceModeSystemPrompt, wrapSpokenInput, } from "./voice-config.js"; -describe("voice MCP stdio config", () => { - test("builds stdio MCP config for voice agent", () => { - const config = buildVoiceAgentMcpServerConfig({ - command: "/usr/local/bin/node", - baseArgs: ["/tmp/mcp-stdio-socket-bridge-cli.mjs"], - socketPath: "/tmp/paseo-voice.sock", - env: { - ELECTRON_RUN_AS_NODE: "1", - PASEO_HOME: "/tmp/paseo-home", - }, - }); - - expect(config.type).toBe("stdio"); - expect(config.command).toBe("/usr/local/bin/node"); - expect(config.args).toEqual([ - "/tmp/mcp-stdio-socket-bridge-cli.mjs", - "--socket", - "/tmp/paseo-voice.sock", - ]); - expect(config.env).toEqual({ - ELECTRON_RUN_AS_NODE: "1", - PASEO_HOME: "/tmp/paseo-home", - }); - expect(config.enabledTools).toEqual(["speak"]); - expect(config.defaultToolsApprovalMode).toBe("prompt"); - expect(config.tools).toEqual({ - speak: { approvalMode: "approve" }, - }); - }); -}); - describe("voice mode prompt instructions", () => { test("builds enabled voice instructions and preserves base prompt", () => { const prompt = buildVoiceModeSystemPrompt("Base system prompt", true); @@ -52,13 +18,13 @@ describe("voice mode prompt instructions", () => { expect(prompt).toContain(""); }); - test("builds enabled voice instructions for a dedicated voice MCP server", () => { + test("builds enabled voice instructions for the generic paseo MCP server", () => { const prompt = buildVoiceModeSystemPrompt("Base system prompt", true, { - voiceToolMcpServerName: PASEO_VOICE_MCP_SERVER_NAME, + voiceToolMcpServerName: PASEO_MCP_SERVER_NAME, }); - expect(prompt).toContain(`Always use the ${PASEO_VOICE_MCP_SERVER_NAME}.speak tool`); - expect(prompt).toContain(`first call ${PASEO_VOICE_MCP_SERVER_NAME}.speak`); + expect(prompt).toContain(`Always use the ${PASEO_MCP_SERVER_NAME}.speak tool`); + expect(prompt).toContain(`first call ${PASEO_MCP_SERVER_NAME}.speak`); }); test("builds disabled voice instructions and supersedes previous voice block", () => { @@ -100,56 +66,11 @@ describe("spoken-input wrapping", () => { expect(wrapSpokenInput("hello")).toContain("Respond using the speak tool only"); }); - test("mentions the dedicated voice MCP tool when configured", () => { + test("mentions the generic paseo MCP tool when configured", () => { expect( wrapSpokenInput("hello", { - voiceToolMcpServerName: PASEO_VOICE_MCP_SERVER_NAME, + voiceToolMcpServerName: PASEO_MCP_SERVER_NAME, }), - ).toContain(`Respond using the ${PASEO_VOICE_MCP_SERVER_NAME}.speak tool only`); - }); -}); - -describe("voice bridge resolution", () => { - test("resolves local socket and pipe listeners", () => { - expect(resolveVoiceAgentBridgeSocketPath("unix:///tmp/paseo.sock")).toBe("/tmp/paseo.sock"); - expect(resolveVoiceAgentBridgeSocketPath("/tmp/paseo.sock")).toBe("/tmp/paseo.sock"); - expect(resolveVoiceAgentBridgeSocketPath("pipe://voice-bridge")).toBe("voice-bridge"); - expect(resolveVoiceAgentBridgeSocketPath("127.0.0.1:1234")).toBeNull(); - }); - - test("builds a dedicated voice MCP bridge config for local listeners", () => { - const config = buildDedicatedVoiceAgentMcpServerConfig({ - listen: "/tmp/paseo.sock", - paseoHome: "/tmp/paseo-home", - }); - - expect(config).not.toBeNull(); - expect(config?.type).toBe("stdio"); - expect(config?.args.at(-2)).toBe("--socket"); - expect(config?.args.at(-1)).toBe("/tmp/paseo.sock"); - expect(config?.env?.PASEO_HOME).toBe("/tmp/paseo-home"); - }); - - test("builds a dedicated HTTP voice MCP config when an agent MCP base URL is available", () => { - const config = buildDedicatedVoiceAgentMcpServerConfig({ - mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", - callerAgentId: "00000000-0000-4000-8000-000000000001", - mcpAuthToken: "test-token", - listen: "127.0.0.1:6767", - paseoHome: "/tmp/paseo-home", - }); - - expect(config).toEqual({ - type: "http", - url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=00000000-0000-4000-8000-000000000001&voiceOnly=1", - enabledTools: ["speak"], - defaultToolsApprovalMode: "prompt", - tools: { - speak: { approvalMode: "approve" }, - }, - headers: { - Authorization: "Bearer test-token", - }, - }); + ).toContain(`Respond using the ${PASEO_MCP_SERVER_NAME}.speak tool only`); }); }); diff --git a/packages/server/src/server/session/voice/voice-session.test.ts b/packages/server/src/server/session/voice/voice-session.test.ts index 7f7b1aa4dc..24890d4025 100644 --- a/packages/server/src/server/session/voice/voice-session.test.ts +++ b/packages/server/src/server/session/voice/voice-session.test.ts @@ -292,17 +292,9 @@ describe("VoiceSession Codex voice MCP overrides", () => { { systemPrompt: "Base system prompt" }, ); - expect(overrides.voiceToolMcpServerName).toBe("paseo_voice"); - expect(overrides.systemPrompt).toContain("paseo_voice.speak"); - expect(overrides.mcpServers?.paseo_voice).toMatchObject({ - type: "http", - url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=00000000-0000-4000-8000-000000000001&voiceOnly=1", - enabledTools: ["speak"], - defaultToolsApprovalMode: "prompt", - tools: { - speak: { approvalMode: "approve" }, - }, - }); + expect(overrides.voiceToolMcpServerName).toBe("paseo"); + expect(overrides.systemPrompt).toContain("paseo.speak"); + expect(overrides.mcpServers).toBeUndefined(); }); test("leaves non-Codex voice-mode overrides on the generic speak tool path", () => { @@ -327,7 +319,7 @@ describe("VoiceSession Codex voice MCP overrides", () => { expect(overrides.voiceToolMcpServerName).toBeUndefined(); expect(overrides.systemPrompt).toContain("speak tool"); - expect(overrides.systemPrompt).not.toContain("paseo_voice.speak"); + expect(overrides.systemPrompt).not.toContain("paseo.speak"); expect(overrides.mcpServers).toBeUndefined(); }); }); diff --git a/packages/server/src/server/session/voice/voice-session.ts b/packages/server/src/server/session/voice/voice-session.ts index 65dbed0981..b6368cd999 100644 --- a/packages/server/src/server/session/voice/voice-session.ts +++ b/packages/server/src/server/session/voice/voice-session.ts @@ -15,18 +15,16 @@ import { } from "../../dictation/dictation-stream-manager.js"; import { createVoiceTurnController, type VoiceTurnController } from "./voice-turn-controller.js"; import { - buildDedicatedVoiceAgentMcpServerConfig, buildVoiceModeSystemPrompt, - PASEO_VOICE_MCP_SERVER_NAME, + PASEO_MCP_SERVER_NAME, stripVoiceModeSystemPrompt, } from "../../voice-config.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "../../voice-types.js"; import type { ManagedAgent } from "../../agent/agent-manager.js"; -import type { AgentSessionConfig, McpServerConfig } from "../../agent/agent-sdk-types.js"; +import type { AgentSessionConfig } from "../../agent/agent-sdk-types.js"; import type { LocalSpeechModelId } from "../../speech/providers/local/models.js"; import { toResolver, type Resolvable } from "../../speech/provider-resolver.js"; import type { SpeechReadinessSnapshot, SpeechReadinessState } from "../../speech/speech-runtime.js"; -import type { DaemonRuntimeConfig } from "../daemon/daemon-session.js"; const PCM_SAMPLE_RATE = 16000; const PCM_CHANNELS = 1; @@ -42,7 +40,6 @@ type ProcessingPhase = "idle" | "transcribing"; interface VoiceModeBaseConfig { systemPrompt?: string; - mcpServers?: Record; voiceToolMcpServerName?: string; } @@ -161,10 +158,6 @@ export interface VoiceSessionOptions { sttLanguage?: string; getSpeechReadiness?: () => SpeechReadinessSnapshot; }; - paseoHome: string; - mcpBaseUrl?: string | null; - mcpAuthToken?: string | null; - daemonRuntimeConfig?: DaemonRuntimeConfig; } /** @@ -215,30 +208,13 @@ export class VoiceSession { ) => void; private readonly unregisterVoiceCallerContext?: (agentId: string) => void; private readonly getSpeechReadiness?: () => SpeechReadinessSnapshot; - private readonly paseoHome: string; - private readonly mcpBaseUrl: string | null; - private readonly mcpAuthToken: string | null; - private readonly daemonRuntimeConfig: DaemonRuntimeConfig | undefined; private voiceModeAgentId: string | null = null; private voiceModeBaseConfig: VoiceModeBaseConfig | null = null; constructor(options: VoiceSessionOptions) { - const { - host, - logger, - sessionId, - sttLanguage, - tts, - stt, - voice, - voiceBridge, - dictation, - paseoHome, - mcpBaseUrl, - mcpAuthToken, - daemonRuntimeConfig, - } = options; + const { host, logger, sessionId, sttLanguage, tts, stt, voice, voiceBridge, dictation } = + options; this.host = host; this.sessionLogger = logger; this.sessionId = sessionId; @@ -251,10 +227,6 @@ export class VoiceSession { this.registerVoiceCallerContext = voiceBridge?.registerVoiceCallerContext; this.unregisterVoiceCallerContext = voiceBridge?.unregisterVoiceCallerContext; this.getSpeechReadiness = dictation?.getSpeechReadiness; - this.paseoHome = paseoHome; - this.mcpBaseUrl = mcpBaseUrl ?? null; - this.mcpAuthToken = mcpAuthToken ?? null; - this.daemonRuntimeConfig = daemonRuntimeConfig; this.ttsManager = new TTSManager(this.sessionId, this.sessionLogger, tts); this.sttManager = new STTManager(this.sessionId, this.sessionLogger, stt, { @@ -515,15 +487,10 @@ export class VoiceSession { const baseConfig: VoiceModeBaseConfig = { systemPrompt: stripVoiceModeSystemPrompt(existing.config.systemPrompt), - mcpServers: existing.config.mcpServers, voiceToolMcpServerName: existing.config.voiceToolMcpServerName, }; this.voiceModeBaseConfig = baseConfig; - const refreshOverrides = this.buildVoiceModeRefreshOverrides( - agentId, - existing.config, - baseConfig, - ); + const refreshOverrides = this.buildVoiceModeRefreshOverrides(existing.config, baseConfig); try { this.sessionLogger.info( @@ -545,49 +512,28 @@ export class VoiceSession { } private buildVoiceModeRefreshOverrides( - agentId: string, existingConfig: AgentSessionConfig, baseConfig: VoiceModeBaseConfig, ): Partial { - const codexVoiceOverrides = this.buildCodexVoiceModeOverrides(agentId, existingConfig); + const codexVoiceOverrides = this.buildCodexVoiceModeOverrides(existingConfig); return { systemPrompt: buildVoiceModeSystemPrompt(baseConfig.systemPrompt, true, { voiceToolMcpServerName: codexVoiceOverrides?.voiceToolMcpServerName ?? baseConfig.voiceToolMcpServerName, }), - ...(codexVoiceOverrides ?? {}), + ...codexVoiceOverrides, }; } private buildCodexVoiceModeOverrides( - agentId: string, existingConfig: AgentSessionConfig, ): Partial | null { if (existingConfig.provider !== "codex") { return null; } - const voiceMcpServer = buildDedicatedVoiceAgentMcpServerConfig({ - mcpBaseUrl: this.mcpBaseUrl, - callerAgentId: agentId, - mcpAuthToken: this.mcpAuthToken, - listen: this.daemonRuntimeConfig?.listen, - paseoHome: this.paseoHome, - }); - if (!voiceMcpServer) { - this.sessionLogger.warn( - { provider: existingConfig.provider, listen: this.daemonRuntimeConfig?.listen ?? null }, - "Voice mode could not resolve a dedicated Codex voice MCP server; falling back to generic speak tool", - ); - return null; - } - return { - mcpServers: { - ...(existingConfig.mcpServers ?? {}), - [PASEO_VOICE_MCP_SERVER_NAME]: voiceMcpServer, - }, - voiceToolMcpServerName: PASEO_VOICE_MCP_SERVER_NAME, + voiceToolMcpServerName: PASEO_MCP_SERVER_NAME, }; } @@ -608,7 +554,6 @@ export class VoiceSession { try { await this.host.reloadAgentSession(agentId, { systemPrompt: buildVoiceModeSystemPrompt(baseConfig.systemPrompt, false), - mcpServers: baseConfig.mcpServers, voiceToolMcpServerName: baseConfig.voiceToolMcpServerName, }); } catch (error) { diff --git a/packages/server/src/server/voice-config.ts b/packages/server/src/server/voice-config.ts index 1e2b353f00..74602f0388 100644 --- a/packages/server/src/server/voice-config.ts +++ b/packages/server/src/server/voice-config.ts @@ -1,16 +1,6 @@ -import { existsSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { buildSelfNodeCommand } from "./paseo-env.js"; - const VOICE_PROMPT_BLOCK_START = ""; const VOICE_PROMPT_BLOCK_END = ""; -const PASEO_SERVER_PACKAGE_NAME = "@getpaseo/server"; - -export const PASEO_VOICE_MCP_SERVER_NAME = "paseo_voice"; -const PASEO_VOICE_ENABLED_TOOLS = ["speak"] as const; +export const PASEO_MCP_SERVER_NAME = "paseo"; function resolveVoiceSpeakToolName(voiceToolMcpServerName?: string): string { return voiceToolMcpServerName ? `${voiceToolMcpServerName}.speak` : "speak"; @@ -82,150 +72,3 @@ export function wrapSpokenInput( const speakToolName = resolveVoiceSpeakToolName(options?.voiceToolMcpServerName); return `\n${text}\n\nThis message was spoken by the user. Respond using the ${speakToolName} tool only, not normal messages, because the user may not be looking at the chat.`; } - -export function buildVoiceAgentMcpServerConfig(params: { - command: string; - baseArgs: string[]; - socketPath: string; - env?: Record; -}): import("./agent/agent-sdk-types.js").McpStdioServerConfig { - return { - type: "stdio", - command: params.command, - args: [...params.baseArgs, "--socket", params.socketPath], - enabledTools: [...PASEO_VOICE_ENABLED_TOOLS], - defaultToolsApprovalMode: "prompt", - tools: { - speak: { approvalMode: "approve" }, - }, - ...(params.env ? { env: params.env } : {}), - }; -} - -export function resolveVoiceAgentBridgeSocketPath( - listen: string | null | undefined, -): string | null { - const trimmed = listen?.trim(); - if (!trimmed) { - return null; - } - if (trimmed.startsWith("\\\\.\\pipe\\")) { - return trimmed; - } - if (trimmed.startsWith("pipe://")) { - return trimmed.slice("pipe://".length); - } - if (trimmed.startsWith("unix://")) { - return trimmed.slice("unix://".length); - } - if (trimmed.startsWith("~/")) { - return path.join(homedir(), trimmed.slice(2)); - } - if (trimmed === "~") { - return homedir(); - } - if (trimmed.startsWith("/")) { - return trimmed; - } - return null; -} - -export function resolveVoiceAgentBridgeScriptPath( - moduleUrl: string = import.meta.url, -): string | null { - const packageRoot = resolvePackageRootFrom(fileURLToPath(moduleUrl), PASEO_SERVER_PACKAGE_NAME); - if (!packageRoot) { - return null; - } - - const bundledPath = path.join(packageRoot, "dist", "scripts", "mcp-stdio-socket-bridge-cli.mjs"); - if (existsSync(bundledPath)) { - return bundledPath; - } - - const sourcePath = path.join(packageRoot, "scripts", "mcp-stdio-socket-bridge-cli.mjs"); - return existsSync(sourcePath) ? sourcePath : null; -} - -export function buildDedicatedVoiceAgentMcpServerConfig(params: { - mcpBaseUrl?: string | null; - callerAgentId?: string | null; - mcpAuthToken?: string | null; - listen: string | null | undefined; - paseoHome: string; - moduleUrl?: string; -}): - | { - type: "http"; - url: string; - headers?: Record; - enabledTools?: string[]; - defaultToolsApprovalMode?: "auto" | "prompt" | "approve"; - tools?: Record; - } - | import("./agent/agent-sdk-types.js").McpStdioServerConfig - | null { - const callerAgentId = params.callerAgentId?.trim(); - if (params.mcpBaseUrl && callerAgentId) { - const url = new URL(params.mcpBaseUrl); - url.searchParams.set("callerAgentId", callerAgentId); - url.searchParams.set("voiceOnly", "1"); - return { - type: "http", - url: url.toString(), - enabledTools: [...PASEO_VOICE_ENABLED_TOOLS], - defaultToolsApprovalMode: "prompt", - tools: { - speak: { approvalMode: "approve" }, - }, - ...(params.mcpAuthToken - ? { headers: { Authorization: `Bearer ${params.mcpAuthToken}` } } - : {}), - }; - } - - const socketPath = resolveVoiceAgentBridgeSocketPath(params.listen); - if (!socketPath) { - return null; - } - - const bridgeScriptPath = resolveVoiceAgentBridgeScriptPath(params.moduleUrl); - if (!bridgeScriptPath) { - return null; - } - - const selfCommand = buildSelfNodeCommand([bridgeScriptPath], { - PASEO_HOME: params.paseoHome, - }); - - return buildVoiceAgentMcpServerConfig({ - command: selfCommand.command, - baseArgs: selfCommand.args, - socketPath, - env: selfCommand.env, - }); -} - -function resolvePackageRootFrom(startPath: string, packageName: string): string | null { - let currentDir = path.dirname(startPath); - - while (true) { - const packageJsonPath = path.join(currentDir, "package.json"); - if (existsSync(packageJsonPath)) { - try { - const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { name?: unknown }; - if (packageJson.name === packageName) { - return currentDir; - } - } catch { - return null; - } - } - - const parentDir = path.dirname(currentDir); - if (parentDir === currentDir) { - return null; - } - currentDir = parentDir; - } -} diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 0757065bcf..cec656af2a 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -472,7 +472,6 @@ export class VoiceAssistantWebSocketServer { private readonly pushTokenStore: PushTokenStore; private readonly pushNotificationSender: PushNotificationSender; private readonly mcpBaseUrl: string | null; - private readonly mcpAuthToken: string | null; private speech!: SpeechService | null; private terminalManager!: TerminalManager | null; private serviceProxy!: ServiceProxySubsystem | null; @@ -516,7 +515,6 @@ export class VoiceAssistantWebSocketServer { paseoHome: string, daemonConfigStore: DaemonConfigStore, mcpBaseUrl: string | null, - mcpAuthToken: string | null, wsConfig: WebSocketServerConfig, workspaceAutoName: WorkspaceAutoName, auth?: DaemonAuthConfig, @@ -583,7 +581,6 @@ export class VoiceAssistantWebSocketServer { this.worktreesRoot = daemonRuntimeConfig?.worktreesRoot; this.daemonConfigStore = daemonConfigStore; this.mcpBaseUrl = mcpBaseUrl; - this.mcpAuthToken = mcpAuthToken; this.assignOptionalServices({ speech, terminalManager, @@ -1176,7 +1173,6 @@ export class VoiceAssistantWebSocketServer { workspaceAutoName: this.workspaceAutoName, daemonConfigStore: this.daemonConfigStore, mcpBaseUrl: this.mcpBaseUrl, - mcpAuthToken: this.mcpAuthToken, stt: () => this.speech?.resolveStt() ?? null, sttLanguage: this.speech?.resolveSttLanguage() ?? "en", tts: () => this.speech?.resolveTts() ?? null, From d030fd4b7ecd3f577ff2132c941fff6f552fac32 Mon Sep 17 00:00:00 2001 From: Martin Troiber Date: Wed, 8 Jul 2026 20:09:03 +0000 Subject: [PATCH 04/13] Test Codex voice with generic speak prompts --- .../server/session.voice-mcp-config.test.ts | 18 ---- .../session/voice/voice-session.test.ts | 83 ------------------- .../src/server/session/voice/voice-session.ts | 5 +- packages/server/src/server/voice-config.ts | 48 ++++------- 4 files changed, 17 insertions(+), 137 deletions(-) diff --git a/packages/server/src/server/session.voice-mcp-config.test.ts b/packages/server/src/server/session.voice-mcp-config.test.ts index 23dc7f4be5..00742afde5 100644 --- a/packages/server/src/server/session.voice-mcp-config.test.ts +++ b/packages/server/src/server/session.voice-mcp-config.test.ts @@ -2,7 +2,6 @@ import { describe, expect, test } from "vitest"; import { buildVoiceModeSystemPrompt, - PASEO_MCP_SERVER_NAME, stripVoiceModeSystemPrompt, wrapSpokenInput, } from "./voice-config.js"; @@ -18,15 +17,6 @@ describe("voice mode prompt instructions", () => { expect(prompt).toContain(""); }); - test("builds enabled voice instructions for the generic paseo MCP server", () => { - const prompt = buildVoiceModeSystemPrompt("Base system prompt", true, { - voiceToolMcpServerName: PASEO_MCP_SERVER_NAME, - }); - - expect(prompt).toContain(`Always use the ${PASEO_MCP_SERVER_NAME}.speak tool`); - expect(prompt).toContain(`first call ${PASEO_MCP_SERVER_NAME}.speak`); - }); - test("builds disabled voice instructions and supersedes previous voice block", () => { const existing = [ "Base system prompt", @@ -65,12 +55,4 @@ describe("spoken-input wrapping", () => { test("defaults to the generic speak tool", () => { expect(wrapSpokenInput("hello")).toContain("Respond using the speak tool only"); }); - - test("mentions the generic paseo MCP tool when configured", () => { - expect( - wrapSpokenInput("hello", { - voiceToolMcpServerName: PASEO_MCP_SERVER_NAME, - }), - ).toContain(`Respond using the ${PASEO_MCP_SERVER_NAME}.speak tool only`); - }); }); diff --git a/packages/server/src/server/session/voice/voice-session.test.ts b/packages/server/src/server/session/voice/voice-session.test.ts index 24890d4025..c3ab6af780 100644 --- a/packages/server/src/server/session/voice/voice-session.test.ts +++ b/packages/server/src/server/session/voice/voice-session.test.ts @@ -3,9 +3,7 @@ import pino from "pino"; import { describe, expect, test, vi } from "vitest"; import { VoiceSession, type VoiceSessionHost } from "./voice-session.js"; -import { createTestLogger } from "../../../test-utils/test-logger.js"; import type { ManagedAgent } from "../../agent/agent-manager.js"; -import type { AgentSessionConfig } from "../../agent/agent-sdk-types.js"; import type { SessionOutboundMessage } from "../../messages.js"; import type { SpeechToTextProvider, @@ -110,34 +108,6 @@ function createStreamingVoiceSession() { return { voiceSession, detector, sttSession, host }; } -function createCodexOverrideVoiceSession() { - return new VoiceSession({ - host: { - emit: () => {}, - loadAgent: async () => { - throw new Error("loadAgent should not be called in this test"); - }, - reloadAgentSession: async () => { - throw new Error("reloadAgentSession should not be called in this test"); - }, - sendSpokenInput: async () => {}, - interruptAgentIfRunning: async () => {}, - hasActiveAgentRun: () => false, - }, - logger: createTestLogger("silent"), - sessionId: "voice-session-config-test", - tts: null, - stt: null, - paseoHome: "/tmp/paseo-home", - mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", - mcpAuthToken: "test-token", - daemonRuntimeConfig: { - listen: "127.0.0.1:6767", - relay: null, - }, - }); -} - async function settle(): Promise { await Promise.resolve(); await Promise.resolve(); @@ -270,56 +240,3 @@ describe("VoiceSession streaming transcription", () => { await voiceSession.cleanup(); }); }); - -describe("VoiceSession Codex voice MCP overrides", () => { - test("builds dedicated Codex voice-mode overrides when an agent MCP base URL is available", () => { - const session = createCodexOverrideVoiceSession(); - - const overrides = ( - session as unknown as { - buildVoiceModeRefreshOverrides: ( - agentId: string, - existingConfig: AgentSessionConfig, - baseConfig: { systemPrompt?: string }, - ) => Partial; - } - ).buildVoiceModeRefreshOverrides( - "00000000-0000-4000-8000-000000000001", - { - provider: "codex", - cwd: "/tmp/project", - }, - { systemPrompt: "Base system prompt" }, - ); - - expect(overrides.voiceToolMcpServerName).toBe("paseo"); - expect(overrides.systemPrompt).toContain("paseo.speak"); - expect(overrides.mcpServers).toBeUndefined(); - }); - - test("leaves non-Codex voice-mode overrides on the generic speak tool path", () => { - const session = createCodexOverrideVoiceSession(); - - const overrides = ( - session as unknown as { - buildVoiceModeRefreshOverrides: ( - agentId: string, - existingConfig: AgentSessionConfig, - baseConfig: { systemPrompt?: string }, - ) => Partial; - } - ).buildVoiceModeRefreshOverrides( - "00000000-0000-4000-8000-000000000001", - { - provider: "claude", - cwd: "/tmp/project", - }, - { systemPrompt: "Base system prompt" }, - ); - - expect(overrides.voiceToolMcpServerName).toBeUndefined(); - expect(overrides.systemPrompt).toContain("speak tool"); - expect(overrides.systemPrompt).not.toContain("paseo.speak"); - expect(overrides.mcpServers).toBeUndefined(); - }); -}); diff --git a/packages/server/src/server/session/voice/voice-session.ts b/packages/server/src/server/session/voice/voice-session.ts index b6368cd999..b339d95ba4 100644 --- a/packages/server/src/server/session/voice/voice-session.ts +++ b/packages/server/src/server/session/voice/voice-session.ts @@ -517,10 +517,7 @@ export class VoiceSession { ): Partial { const codexVoiceOverrides = this.buildCodexVoiceModeOverrides(existingConfig); return { - systemPrompt: buildVoiceModeSystemPrompt(baseConfig.systemPrompt, true, { - voiceToolMcpServerName: - codexVoiceOverrides?.voiceToolMcpServerName ?? baseConfig.voiceToolMcpServerName, - }), + systemPrompt: buildVoiceModeSystemPrompt(baseConfig.systemPrompt, true), ...codexVoiceOverrides, }; } diff --git a/packages/server/src/server/voice-config.ts b/packages/server/src/server/voice-config.ts index 74602f0388..618cb3e5b5 100644 --- a/packages/server/src/server/voice-config.ts +++ b/packages/server/src/server/voice-config.ts @@ -1,26 +1,18 @@ const VOICE_PROMPT_BLOCK_START = ""; const VOICE_PROMPT_BLOCK_END = ""; export const PASEO_MCP_SERVER_NAME = "paseo"; - -function resolveVoiceSpeakToolName(voiceToolMcpServerName?: string): string { - return voiceToolMcpServerName ? `${voiceToolMcpServerName}.speak` : "speak"; -} - -function buildEnabledVoiceAgentSystemInstruction(voiceToolMcpServerName?: string): string { - const speakToolName = resolveVoiceSpeakToolName(voiceToolMcpServerName); - return [ - "Paseo voice mode is now on.", - "You are the Paseo voice assistant.", - "The user cannot see your chat messages or tool calls.", - `Always use the ${speakToolName} tool for all user-facing communication.`, - `Before calling any non-speech tool, first call ${speakToolName} with a short acknowledgement of what you heard and what you will do next.`, - `For long-running work, use ${speakToolName} to provide progress updates before and during execution.`, - "Treat the user input as transcribed speech.", - "If the user intent is clear, proceed without extra confirmation.", - `If the transcription seems incomplete, cut off, ambiguous, or may contain a non-obvious mistake or misspelling, ask a clarifying question via ${speakToolName} before taking action.`, - "Use concise plain language suitable for speech output.", - ].join(" "); -} +const VOICE_AGENT_SYSTEM_INSTRUCTION = [ + "Paseo voice mode is now on.", + "You are the Paseo voice assistant.", + "The user cannot see your chat messages or tool calls.", + "Always use the speak tool for all user-facing communication.", + "Before calling any non-speech tool, first call speak with a short acknowledgement of what you heard and what you will do next.", + "For long-running work, use speak to provide progress updates before and during execution.", + "Treat the user input as transcribed speech.", + "If the user intent is clear, proceed without extra confirmation.", + "If the transcription seems incomplete, cut off, ambiguous, or may contain a non-obvious mistake or misspelling, ask a clarifying question via speak before taking action.", + "Use concise plain language suitable for speech output.", +].join(" "); const VOICE_AGENT_DISABLED_INSTRUCTION = [ "Paseo voice mode is now off.", @@ -47,14 +39,10 @@ export function stripVoiceModeSystemPrompt(existing?: string): string | undefine return stripped.length > 0 ? stripped : undefined; } -export function buildVoiceModeSystemPrompt( - existing: string | undefined, - enabled: boolean, - options?: { voiceToolMcpServerName?: string }, -): string { +export function buildVoiceModeSystemPrompt(existing: string | undefined, enabled: boolean): string { const basePrompt = stripVoiceModeSystemPrompt(existing); const voiceInstruction = enabled - ? buildEnabledVoiceAgentSystemInstruction(options?.voiceToolMcpServerName) + ? VOICE_AGENT_SYSTEM_INSTRUCTION : VOICE_AGENT_DISABLED_INSTRUCTION; const voiceBlock = [VOICE_PROMPT_BLOCK_START, voiceInstruction, VOICE_PROMPT_BLOCK_END].join( "\n", @@ -65,10 +53,6 @@ export function buildVoiceModeSystemPrompt( .join("\n\n"); } -export function wrapSpokenInput( - text: string, - options?: { voiceToolMcpServerName?: string }, -): string { - const speakToolName = resolveVoiceSpeakToolName(options?.voiceToolMcpServerName); - return `\n${text}\n\nThis message was spoken by the user. Respond using the ${speakToolName} tool only, not normal messages, because the user may not be looking at the chat.`; +export function wrapSpokenInput(text: string): string { + return `\n${text}\n\nThis message was spoken by the user. Respond using the speak tool only, not normal messages, because the user may not be looking at the chat.`; } From 44e2b960d7bae1ad3ef9511a9ab813b58ffba6c4 Mon Sep 17 00:00:00 2001 From: Martin Troiber Date: Wed, 8 Jul 2026 22:06:40 +0000 Subject: [PATCH 05/13] Align voice tests and prompt wording with main --- .../src/server/session/voice/voice-session.test.ts | 13 ++++--------- packages/server/src/server/voice-config.ts | 2 +- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/server/src/server/session/voice/voice-session.test.ts b/packages/server/src/server/session/voice/voice-session.test.ts index c3ab6af780..d6232ff4d8 100644 --- a/packages/server/src/server/session/voice/voice-session.test.ts +++ b/packages/server/src/server/session/voice/voice-session.test.ts @@ -79,7 +79,7 @@ function createFakeHost(): FakeVoiceHost { }; } -function createStreamingVoiceSession() { +function createVoiceSession() { const detector = new FakeVoiceTurnDetectionSession(); const sttSession = new FakeVoiceSttSession(); const stt: SpeechToTextProvider = { @@ -99,11 +99,6 @@ function createStreamingVoiceSession() { tts: null, stt, voice: { turnDetection }, - paseoHome: "/tmp/paseo-home", - daemonRuntimeConfig: { - listen: "/tmp/paseo.sock", - relay: null, - }, }); return { voiceSession, detector, sttSession, host }; } @@ -142,7 +137,7 @@ describe("VoiceSession streaming transcription", () => { }); test("delivers the streaming final transcript to the agent exactly once", async () => { - const { voiceSession, detector, sttSession, host } = createStreamingVoiceSession(); + const { voiceSession, detector, sttSession, host } = createVoiceSession(); await voiceSession.handleSetVoiceMode(true, VOICE_AGENT_ID); detector.emit("speech_started"); @@ -181,7 +176,7 @@ describe("VoiceSession streaming transcription", () => { test("emits an empty transcript on finalization timeout without submitting to the agent", async () => { vi.useFakeTimers(); try { - const { voiceSession, detector, sttSession, host } = createStreamingVoiceSession(); + const { voiceSession, detector, sttSession, host } = createVoiceSession(); await voiceSession.handleSetVoiceMode(true, VOICE_AGENT_ID); detector.emit("speech_started"); @@ -208,7 +203,7 @@ describe("VoiceSession streaming transcription", () => { }); test("filters a low-confidence streaming final without submitting to the agent", async () => { - const { voiceSession, detector, sttSession, host } = createStreamingVoiceSession(); + const { voiceSession, detector, sttSession, host } = createVoiceSession(); await voiceSession.handleSetVoiceMode(true, VOICE_AGENT_ID); detector.emit("speech_started"); diff --git a/packages/server/src/server/voice-config.ts b/packages/server/src/server/voice-config.ts index 618cb3e5b5..dbc5be68cb 100644 --- a/packages/server/src/server/voice-config.ts +++ b/packages/server/src/server/voice-config.ts @@ -6,7 +6,7 @@ const VOICE_AGENT_SYSTEM_INSTRUCTION = [ "You are the Paseo voice assistant.", "The user cannot see your chat messages or tool calls.", "Always use the speak tool for all user-facing communication.", - "Before calling any non-speech tool, first call speak with a short acknowledgement of what you heard and what you will do next.", + "Before calling any non-speak tool, first call speak with a short acknowledgement of what you heard and what you will do next.", "For long-running work, use speak to provide progress updates before and during execution.", "Treat the user input as transcribed speech.", "If the user intent is clear, proceed without extra confirmation.", From 1833004a71389608cb260456bac5aba01e73bc09 Mon Sep 17 00:00:00 2001 From: Martin Troiber Date: Tue, 21 Jul 2026 20:52:34 +0000 Subject: [PATCH 06/13] Isolate Codex MCP approval policy --- docs/codex-voice-mcp-approval.md | 5 ++- .../src/server/agent/agent-manager.test.ts | 24 ++++++++------ .../server/src/server/agent/agent-manager.ts | 1 + .../src/server/agent/agent-sdk-types.ts | 29 ++--------------- .../providers/codex-app-server-agent.test.ts | 8 +++-- .../agent/providers/codex-app-server-agent.ts | 29 ++++++++++++----- .../server/agent/runtime-mcp-config.test.ts | 32 ++++++++++++++++--- .../src/server/agent/runtime-mcp-config.ts | 24 ++++++++------ 8 files changed, 89 insertions(+), 63 deletions(-) diff --git a/docs/codex-voice-mcp-approval.md b/docs/codex-voice-mcp-approval.md index 48dd317c42..b8a37fd36b 100644 --- a/docs/codex-voice-mcp-approval.md +++ b/docs/codex-voice-mcp-approval.md @@ -6,7 +6,7 @@ Codex voice-mode sessions were receiving the injected `paseo` MCP server, but Pa ## Fix -Paseo now carries MCP approval metadata in its shared agent config and forwards it to Codex as: +Paseo now carries Codex MCP approval metadata in a Codex-specific runtime policy and forwards it only through the Codex adapter as: - `mcp_servers..enabled_tools` - `mcp_servers..default_tools_approval_mode` @@ -20,6 +20,9 @@ For Codex voice mode, the injected `paseo` server is now configured with: This keeps the approval override limited to `speak` while leaving the normal generic `paseo` MCP server in place. +The voice approval override is intentionally Codex-only. Other providers use different permission systems or do not +accept these Codex-specific MCP approval fields, so they receive the generic `paseo` MCP server without this metadata. + ## Verification 1. Build or restart the Paseo server from this branch. diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 59d8387da4..51ef609bb3 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -3776,23 +3776,27 @@ test("Codex voice MCP sessions use the generic paseo MCP injection with voice ap }); manager.setMcpBaseUrl("http://127.0.0.1:4123/mcp/agents"); - await manager.createAgent({ - provider: "codex", - cwd: workdir, - voiceToolMcpServerName: PASEO_MCP_SERVER_NAME, - }); + await manager.createAgent( + { + provider: "codex", + cwd: workdir, + voiceToolMcpServerName: PASEO_MCP_SERVER_NAME, + }, + undefined, + { workspaceId: undefined }, + ); expect(client.createdConfigs).toHaveLength(1); - expect(client.createdConfigs[0]?.mcpServers).toMatchObject({ + expect(client.createdConfigs[0]?.codexMcpServerPolicies).toMatchObject({ [PASEO_MCP_SERVER_NAME]: { - type: "http", enabledTools: ["speak"], defaultToolsApprovalMode: "prompt", - tools: { - speak: { approvalMode: "approve" }, - }, + tools: { speak: { approvalMode: "approve" } }, }, }); + expect(client.createdConfigs[0]?.mcpServers).toMatchObject({ + [PASEO_MCP_SERVER_NAME]: { type: "http" }, + }); expect(client.createdConfigs[0]?.mcpServers?.[PASEO_MCP_SERVER_NAME]?.type).toBe("http"); expect(client.createdConfigs[0]?.mcpServers?.[PASEO_MCP_SERVER_NAME]).toHaveProperty( "url", diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index cfda95c1bc..b2db084f4d 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -4188,6 +4188,7 @@ export class AgentManager { const launchConfig = this.applyDaemonAppendSystemPrompt( withRuntimePaseoMcpServer({ config: storedConfig, + provider: storedConfig.provider, agentId, mcpBaseUrl: this.mcpBaseUrl, mcpAuthToken: this.mcpAuthToken, diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 1b43ccd30d..790b743b36 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -24,15 +24,6 @@ export interface McpStdioServerConfig { * and never deferred behind tool search. Honored by the Claude provider. */ alwaysLoad?: boolean; - enabledTools?: string[]; - disabledTools?: string[]; - defaultToolsApprovalMode?: "auto" | "prompt" | "approve"; - tools?: Record< - string, - { - approvalMode?: "auto" | "prompt" | "approve"; - } - >; } /** @@ -47,15 +38,6 @@ export interface McpHttpServerConfig { * and never deferred behind tool search. Honored by the Claude provider. */ alwaysLoad?: boolean; - enabledTools?: string[]; - disabledTools?: string[]; - defaultToolsApprovalMode?: "auto" | "prompt" | "approve"; - tools?: Record< - string, - { - approvalMode?: "auto" | "prompt" | "approve"; - } - >; } /** @@ -70,15 +52,6 @@ export interface McpSseServerConfig { * and never deferred behind tool search. Honored by the Claude provider. */ alwaysLoad?: boolean; - enabledTools?: string[]; - disabledTools?: string[]; - defaultToolsApprovalMode?: "auto" | "prompt" | "approve"; - tools?: Record< - string, - { - approvalMode?: "auto" | "prompt" | "approve"; - } - >; } /** @@ -604,6 +577,8 @@ export interface AgentSessionConfig { claude?: Partial; }; mcpServers?: Record; + /** Runtime-only provider extension, interpreted only by the matching provider. */ + codexMcpServerPolicies?: Record>; /** * Internal voice-mode override for provider-specific speech tool routing. * This is persisted with the session config so reloads can apply the same diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index b11f3c0d26..8ff661e83e 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -1086,11 +1086,13 @@ describe("Codex app-server provider", () => { paseo: { type: "http", url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=test", + }, + }, + codexMcpServerPolicies: { + paseo: { enabledTools: ["speak"], defaultToolsApprovalMode: "prompt", - tools: { - speak: { approvalMode: "approve" }, - }, + tools: { speak: { approvalMode: "approve" } }, }, }, }); 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 ed26204cff..e7b4a15243 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 @@ -798,17 +798,27 @@ interface CodexMcpServerConfig { >; } -function toCodexMcpConfig(config: McpServerConfig): CodexMcpServerConfig { +interface CodexMcpServerPolicy { + enabledTools?: string[]; + disabledTools?: string[]; + defaultToolsApprovalMode?: "auto" | "prompt" | "approve"; + tools?: Record; +} + +function toCodexMcpConfig( + config: McpServerConfig, + policy?: CodexMcpServerPolicy, +): CodexMcpServerConfig { const base = { - ...(config.enabledTools ? { enabled_tools: config.enabledTools } : {}), - ...(config.disabledTools ? { disabled_tools: config.disabledTools } : {}), - ...(config.defaultToolsApprovalMode - ? { default_tools_approval_mode: config.defaultToolsApprovalMode } + ...(policy?.enabledTools ? { enabled_tools: policy.enabledTools } : {}), + ...(policy?.disabledTools ? { disabled_tools: policy.disabledTools } : {}), + ...(policy?.defaultToolsApprovalMode + ? { default_tools_approval_mode: policy.defaultToolsApprovalMode } : {}), - ...(config.tools + ...(policy?.tools ? { tools: Object.fromEntries( - Object.entries(config.tools).map(([toolName, toolConfig]) => [ + Object.entries(policy.tools).map(([toolName, toolConfig]) => [ toolName, toolConfig.approvalMode ? { approval_mode: toolConfig.approvalMode } : {}, ]), @@ -4542,7 +4552,10 @@ export class CodexAppServerAgentSession implements AgentSession { if (this.config.mcpServers) { const mcpServers: Record = {}; for (const [name, serverConfig] of Object.entries(this.config.mcpServers)) { - mcpServers[name] = toCodexMcpConfig(serverConfig); + mcpServers[name] = toCodexMcpConfig( + serverConfig, + this.config.codexMcpServerPolicies?.[name] as CodexMcpServerPolicy | undefined, + ); } innerConfig.mcp_servers = mcpServers; } diff --git a/packages/server/src/server/agent/runtime-mcp-config.test.ts b/packages/server/src/server/agent/runtime-mcp-config.test.ts index 605e7f27a5..9618f90e1f 100644 --- a/packages/server/src/server/agent/runtime-mcp-config.test.ts +++ b/packages/server/src/server/agent/runtime-mcp-config.test.ts @@ -12,6 +12,7 @@ describe("withRuntimePaseoMcpServer", () => { test("injects the paseo MCP server with a bearer header when a token is provided", () => { const result = withRuntimePaseoMcpServer({ config: BASE_CONFIG, + provider: BASE_CONFIG.provider, agentId: "agent-1", mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", mcpAuthToken: "cap-token", @@ -27,6 +28,7 @@ describe("withRuntimePaseoMcpServer", () => { test("omits the header when no token is available", () => { const result = withRuntimePaseoMcpServer({ config: BASE_CONFIG, + provider: BASE_CONFIG.provider, agentId: "agent-1", mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", mcpAuthToken: null, @@ -41,6 +43,7 @@ describe("withRuntimePaseoMcpServer", () => { test("does not inject when no MCP base URL is configured", () => { const result = withRuntimePaseoMcpServer({ config: BASE_CONFIG, + provider: BASE_CONFIG.provider, agentId: "agent-1", mcpBaseUrl: null, mcpAuthToken: "cap-token", @@ -56,19 +59,40 @@ describe("withRuntimePaseoMcpServer", () => { cwd: "/tmp/agent", voiceToolMcpServerName: "paseo", }, + provider: "codex", agentId: "agent-1", mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", mcpAuthToken: null, }); + expect(result.codexMcpServerPolicies?.paseo).toEqual({ + enabledTools: ["speak"], + defaultToolsApprovalMode: "prompt", + tools: { speak: { approvalMode: "approve" } }, + }); expect(result.mcpServers?.paseo).toEqual({ type: "http", url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1", - enabledTools: ["speak"], - defaultToolsApprovalMode: "prompt", - tools: { - speak: { approvalMode: "approve" }, + }); + }); + + test("does not apply Codex voice approval metadata to other providers", () => { + const result = withRuntimePaseoMcpServer({ + config: { + provider: "claude", + cwd: "/tmp/agent", + voiceToolMcpServerName: "paseo", }, + provider: "claude", + agentId: "agent-1", + mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", + mcpAuthToken: null, + }); + + expect(result.mcpServers?.paseo).toEqual({ + type: "http", + url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1", }); + expect(result.codexMcpServerPolicies).toBeUndefined(); }); }); diff --git a/packages/server/src/server/agent/runtime-mcp-config.ts b/packages/server/src/server/agent/runtime-mcp-config.ts index 38dfdc9305..69f93ecc08 100644 --- a/packages/server/src/server/agent/runtime-mcp-config.ts +++ b/packages/server/src/server/agent/runtime-mcp-config.ts @@ -29,6 +29,7 @@ export function stripInternalPaseoMcpServer(config: AgentSessionConfig): AgentSe export function withRuntimePaseoMcpServer(params: { config: AgentSessionConfig; + provider: string; agentId: string; mcpBaseUrl: string | null; /** @@ -43,22 +44,25 @@ export function withRuntimePaseoMcpServer(params: { return storedConfig; } - const applyVoiceApproval = storedConfig.voiceToolMcpServerName === PASEO_MCP_SERVER_NAME; + const applyVoiceApproval = + params.provider === "codex" && storedConfig.voiceToolMcpServerName === PASEO_MCP_SERVER_NAME; return { ...storedConfig, + ...(applyVoiceApproval + ? { + codexMcpServerPolicies: { + [PASEO_MCP_SERVER_NAME]: { + enabledTools: [...PASEO_VOICE_ENABLED_TOOLS], + defaultToolsApprovalMode: "prompt" as const, + tools: { speak: { approvalMode: "approve" as const } }, + }, + }, + } + : {}), mcpServers: { [PASEO_MCP_SERVER_NAME]: { type: "http", url: `${params.mcpBaseUrl}?callerAgentId=${params.agentId}`, - ...(applyVoiceApproval - ? { - enabledTools: [...PASEO_VOICE_ENABLED_TOOLS], - defaultToolsApprovalMode: "prompt" as const, - tools: { - speak: { approvalMode: "approve" as const }, - }, - } - : {}), ...(params.mcpAuthToken ? { headers: { Authorization: `Bearer ${params.mcpAuthToken}` } } : {}), From d751cf3dbebf56a808bfc91a4d8717de5868a524 Mon Sep 17 00:00:00 2001 From: Martin Troiber Date: Wed, 22 Jul 2026 22:11:38 +0000 Subject: [PATCH 07/13] Keep Codex voice policy in provider config --- docs/codex-voice-mcp-approval.md | 2 +- .../src/server/agent/agent-manager.test.ts | 14 ++++- .../server/src/server/agent/agent-manager.ts | 1 - .../src/server/agent/agent-sdk-types.ts | 9 ---- .../providers/codex-app-server-agent.test.ts | 14 +++-- .../agent/providers/codex-app-server-agent.ts | 14 ++--- .../server/agent/runtime-mcp-config.test.ts | 47 ---------------- .../src/server/agent/runtime-mcp-config.ts | 15 ------ .../session/voice/voice-session.test.ts | 54 ++++++++++++++++--- .../src/server/session/voice/voice-session.ts | 24 +++++++-- 10 files changed, 98 insertions(+), 96 deletions(-) diff --git a/docs/codex-voice-mcp-approval.md b/docs/codex-voice-mcp-approval.md index b8a37fd36b..0520d6bbea 100644 --- a/docs/codex-voice-mcp-approval.md +++ b/docs/codex-voice-mcp-approval.md @@ -6,7 +6,7 @@ Codex voice-mode sessions were receiving the injected `paseo` MCP server, but Pa ## Fix -Paseo now carries Codex MCP approval metadata in a Codex-specific runtime policy and forwards it only through the Codex adapter as: +Paseo now carries Codex MCP approval metadata in `extra.codex.mcpServerPolicies` and forwards it only through the Codex adapter as: - `mcp_servers..enabled_tools` - `mcp_servers..default_tools_approval_mode` diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 51ef609bb3..fc912db66f 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -3780,14 +3780,24 @@ test("Codex voice MCP sessions use the generic paseo MCP injection with voice ap { provider: "codex", cwd: workdir, - voiceToolMcpServerName: PASEO_MCP_SERVER_NAME, + extra: { + codex: { + mcpServerPolicies: { + [PASEO_MCP_SERVER_NAME]: { + enabledTools: ["speak"], + defaultToolsApprovalMode: "prompt", + tools: { speak: { approvalMode: "approve" } }, + }, + }, + }, + }, }, undefined, { workspaceId: undefined }, ); expect(client.createdConfigs).toHaveLength(1); - expect(client.createdConfigs[0]?.codexMcpServerPolicies).toMatchObject({ + expect(client.createdConfigs[0]?.extra?.codex?.mcpServerPolicies).toMatchObject({ [PASEO_MCP_SERVER_NAME]: { enabledTools: ["speak"], defaultToolsApprovalMode: "prompt", diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index b2db084f4d..cfda95c1bc 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -4188,7 +4188,6 @@ export class AgentManager { const launchConfig = this.applyDaemonAppendSystemPrompt( withRuntimePaseoMcpServer({ config: storedConfig, - provider: storedConfig.provider, agentId, mcpBaseUrl: this.mcpBaseUrl, mcpAuthToken: this.mcpAuthToken, diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 790b743b36..6836cd8bc4 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -577,15 +577,6 @@ export interface AgentSessionConfig { claude?: Partial; }; mcpServers?: Record; - /** Runtime-only provider extension, interpreted only by the matching provider. */ - codexMcpServerPolicies?: Record>; - /** - * Internal voice-mode override for provider-specific speech tool routing. - * This is persisted with the session config so reloads can apply the same - * launch behavior until voice mode is disabled and the original config is - * restored. - */ - voiceToolMcpServerName?: string; /** * Internal agents are hidden from listings and don't trigger notifications. * They are used for ephemeral system tasks like commit/PR generation. diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 8ff661e83e..228d6318da 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -1088,11 +1088,15 @@ describe("Codex app-server provider", () => { url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=test", }, }, - codexMcpServerPolicies: { - paseo: { - enabledTools: ["speak"], - defaultToolsApprovalMode: "prompt", - tools: { speak: { approvalMode: "approve" } }, + extra: { + codex: { + mcpServerPolicies: { + paseo: { + enabledTools: ["speak"], + defaultToolsApprovalMode: "prompt", + tools: { speak: { approvalMode: "approve" } }, + }, + }, }, }, }); 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 e7b4a15243..1d7e0bc022 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 @@ -4549,18 +4549,20 @@ export class CodexAppServerAgentSession implements AgentSession { private buildCodexInnerConfig(): Record | null { const innerConfig: Record = {}; + const codexExtra = this.config.extra?.codex; + const mcpServerPolicies = codexExtra?.mcpServerPolicies as + | Record + | undefined; if (this.config.mcpServers) { const mcpServers: Record = {}; for (const [name, serverConfig] of Object.entries(this.config.mcpServers)) { - mcpServers[name] = toCodexMcpConfig( - serverConfig, - this.config.codexMcpServerPolicies?.[name] as CodexMcpServerPolicy | undefined, - ); + mcpServers[name] = toCodexMcpConfig(serverConfig, mcpServerPolicies?.[name]); } innerConfig.mcp_servers = mcpServers; } - if (this.config.extra?.codex) { - Object.assign(innerConfig, this.config.extra.codex); + if (codexExtra) { + const { mcpServerPolicies: _mcpServerPolicies, ...nativeCodexExtra } = codexExtra; + Object.assign(innerConfig, nativeCodexExtra); } if (this.deps.customCodexConfig) { Object.assign(innerConfig, this.deps.customCodexConfig); diff --git a/packages/server/src/server/agent/runtime-mcp-config.test.ts b/packages/server/src/server/agent/runtime-mcp-config.test.ts index 9618f90e1f..4704d131bb 100644 --- a/packages/server/src/server/agent/runtime-mcp-config.test.ts +++ b/packages/server/src/server/agent/runtime-mcp-config.test.ts @@ -12,7 +12,6 @@ describe("withRuntimePaseoMcpServer", () => { test("injects the paseo MCP server with a bearer header when a token is provided", () => { const result = withRuntimePaseoMcpServer({ config: BASE_CONFIG, - provider: BASE_CONFIG.provider, agentId: "agent-1", mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", mcpAuthToken: "cap-token", @@ -28,7 +27,6 @@ describe("withRuntimePaseoMcpServer", () => { test("omits the header when no token is available", () => { const result = withRuntimePaseoMcpServer({ config: BASE_CONFIG, - provider: BASE_CONFIG.provider, agentId: "agent-1", mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", mcpAuthToken: null, @@ -43,7 +41,6 @@ describe("withRuntimePaseoMcpServer", () => { test("does not inject when no MCP base URL is configured", () => { const result = withRuntimePaseoMcpServer({ config: BASE_CONFIG, - provider: BASE_CONFIG.provider, agentId: "agent-1", mcpBaseUrl: null, mcpAuthToken: "cap-token", @@ -51,48 +48,4 @@ describe("withRuntimePaseoMcpServer", () => { expect(result.mcpServers).toBeUndefined(); }); - - test("adds voice approval metadata for Codex voice sessions on the generic paseo server", () => { - const result = withRuntimePaseoMcpServer({ - config: { - provider: "codex", - cwd: "/tmp/agent", - voiceToolMcpServerName: "paseo", - }, - provider: "codex", - agentId: "agent-1", - mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", - mcpAuthToken: null, - }); - - expect(result.codexMcpServerPolicies?.paseo).toEqual({ - enabledTools: ["speak"], - defaultToolsApprovalMode: "prompt", - tools: { speak: { approvalMode: "approve" } }, - }); - expect(result.mcpServers?.paseo).toEqual({ - type: "http", - url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1", - }); - }); - - test("does not apply Codex voice approval metadata to other providers", () => { - const result = withRuntimePaseoMcpServer({ - config: { - provider: "claude", - cwd: "/tmp/agent", - voiceToolMcpServerName: "paseo", - }, - provider: "claude", - agentId: "agent-1", - mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", - mcpAuthToken: null, - }); - - expect(result.mcpServers?.paseo).toEqual({ - type: "http", - url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1", - }); - expect(result.codexMcpServerPolicies).toBeUndefined(); - }); }); diff --git a/packages/server/src/server/agent/runtime-mcp-config.ts b/packages/server/src/server/agent/runtime-mcp-config.ts index 69f93ecc08..af04e8021c 100644 --- a/packages/server/src/server/agent/runtime-mcp-config.ts +++ b/packages/server/src/server/agent/runtime-mcp-config.ts @@ -2,7 +2,6 @@ import type { AgentSessionConfig, McpServerConfig } from "./agent-sdk-types.js"; const PASEO_MCP_SERVER_NAME = "paseo"; const PASEO_MCP_PATHNAME = "/mcp/agents"; -const PASEO_VOICE_ENABLED_TOOLS = ["speak"] as const; export function stripInternalPaseoMcpServer(config: AgentSessionConfig): AgentSessionConfig { const mcpServers = config.mcpServers; @@ -29,7 +28,6 @@ export function stripInternalPaseoMcpServer(config: AgentSessionConfig): AgentSe export function withRuntimePaseoMcpServer(params: { config: AgentSessionConfig; - provider: string; agentId: string; mcpBaseUrl: string | null; /** @@ -44,21 +42,8 @@ export function withRuntimePaseoMcpServer(params: { return storedConfig; } - const applyVoiceApproval = - params.provider === "codex" && storedConfig.voiceToolMcpServerName === PASEO_MCP_SERVER_NAME; return { ...storedConfig, - ...(applyVoiceApproval - ? { - codexMcpServerPolicies: { - [PASEO_MCP_SERVER_NAME]: { - enabledTools: [...PASEO_VOICE_ENABLED_TOOLS], - defaultToolsApprovalMode: "prompt" as const, - tools: { speak: { approvalMode: "approve" as const } }, - }, - }, - } - : {}), mcpServers: { [PASEO_MCP_SERVER_NAME]: { type: "http", diff --git a/packages/server/src/server/session/voice/voice-session.test.ts b/packages/server/src/server/session/voice/voice-session.test.ts index d6232ff4d8..f2780f708a 100644 --- a/packages/server/src/server/session/voice/voice-session.test.ts +++ b/packages/server/src/server/session/voice/voice-session.test.ts @@ -4,6 +4,7 @@ import { describe, expect, test, vi } from "vitest"; import { VoiceSession, type VoiceSessionHost } from "./voice-session.js"; import type { ManagedAgent } from "../../agent/agent-manager.js"; +import type { AgentSessionConfig } from "../../agent/agent-sdk-types.js"; import type { SessionOutboundMessage } from "../../messages.js"; import type { SpeechToTextProvider, @@ -57,20 +58,27 @@ class FakeVoiceSttSession extends EventEmitter implements StreamingTranscription interface FakeVoiceHost extends VoiceSessionHost { readonly emitted: SessionOutboundMessage[]; readonly spokenInput: Array<{ agentId: string; text: string }>; + readonly reloadedConfigs: Array | undefined>; } -function createFakeHost(): FakeVoiceHost { +function createFakeHost( + config: AgentSessionConfig = { provider: "claude", cwd: "/tmp" }, +): FakeVoiceHost { const emitted: SessionOutboundMessage[] = []; const spokenInput: Array<{ agentId: string; text: string }> = []; + const reloadedConfigs: Array | undefined> = []; return { emitted, spokenInput, + reloadedConfigs, emit: (msg) => { emitted.push(msg); }, - loadAgent: async (agentId) => - ({ id: agentId, config: { systemPrompt: undefined } }) as unknown as ManagedAgent, - reloadAgentSession: async (agentId) => ({ id: agentId }) as unknown as ManagedAgent, + loadAgent: async (agentId) => ({ id: agentId, config }) as unknown as ManagedAgent, + reloadAgentSession: async (agentId, overrides) => { + reloadedConfigs.push(overrides); + return { id: agentId } as unknown as ManagedAgent; + }, sendSpokenInput: async (agentId, text) => { spokenInput.push({ agentId, text }); }, @@ -79,7 +87,7 @@ function createFakeHost(): FakeVoiceHost { }; } -function createVoiceSession() { +function createVoiceSession(config?: AgentSessionConfig) { const detector = new FakeVoiceTurnDetectionSession(); const sttSession = new FakeVoiceSttSession(); const stt: SpeechToTextProvider = { @@ -90,7 +98,7 @@ function createVoiceSession() { id: "local", createSession: vi.fn(() => detector), }; - const host = createFakeHost(); + const host = createFakeHost(config); const voiceSession = new VoiceSession({ host, logger: pino({ level: "silent" }), @@ -103,6 +111,40 @@ function createVoiceSession() { return { voiceSession, detector, sttSession, host }; } +test("Codex voice mode adds and restores its MCP policy in provider-specific config", async () => { + const originalExtra = { codex: { model_provider: "custom" } }; + const { voiceSession, host } = createVoiceSession({ + provider: "codex", + cwd: "/tmp", + extra: originalExtra, + }); + + await voiceSession.handleSetVoiceMode(true, VOICE_AGENT_ID); + + expect(host.reloadedConfigs[0]).toMatchObject({ + extra: { + codex: { + model_provider: "custom", + mcpServerPolicies: { + paseo: { + enabledTools: ["speak"], + defaultToolsApprovalMode: "prompt", + tools: { speak: { approvalMode: "approve" } }, + }, + }, + }, + }, + }); + + await voiceSession.handleSetVoiceMode(false, VOICE_AGENT_ID); + + expect(host.reloadedConfigs[1]).toEqual({ + systemPrompt: expect.any(String), + extra: originalExtra, + }); + await voiceSession.cleanup(); +}); + async function settle(): Promise { await Promise.resolve(); await Promise.resolve(); diff --git a/packages/server/src/server/session/voice/voice-session.ts b/packages/server/src/server/session/voice/voice-session.ts index b339d95ba4..6983a0ef8d 100644 --- a/packages/server/src/server/session/voice/voice-session.ts +++ b/packages/server/src/server/session/voice/voice-session.ts @@ -35,12 +35,17 @@ const MIN_STREAMING_SEGMENT_BYTES = Math.round( PCM_BYTES_PER_MS * MIN_STREAMING_SEGMENT_DURATION_MS, ); const AgentIdSchema = z.guid(); +const CODEX_VOICE_MCP_POLICY = { + enabledTools: ["speak"], + defaultToolsApprovalMode: "prompt", + tools: { speak: { approvalMode: "approve" } }, +} as const; type ProcessingPhase = "idle" | "transcribing"; interface VoiceModeBaseConfig { systemPrompt?: string; - voiceToolMcpServerName?: string; + extra?: AgentSessionConfig["extra"]; } interface AudioBufferState { @@ -487,7 +492,7 @@ export class VoiceSession { const baseConfig: VoiceModeBaseConfig = { systemPrompt: stripVoiceModeSystemPrompt(existing.config.systemPrompt), - voiceToolMcpServerName: existing.config.voiceToolMcpServerName, + extra: existing.config.extra, }; this.voiceModeBaseConfig = baseConfig; const refreshOverrides = this.buildVoiceModeRefreshOverrides(existing.config, baseConfig); @@ -530,7 +535,18 @@ export class VoiceSession { } return { - voiceToolMcpServerName: PASEO_MCP_SERVER_NAME, + extra: { + ...existingConfig.extra, + codex: { + ...existingConfig.extra?.codex, + mcpServerPolicies: { + ...(existingConfig.extra?.codex?.mcpServerPolicies as + | Record + | undefined), + [PASEO_MCP_SERVER_NAME]: CODEX_VOICE_MCP_POLICY, + }, + }, + }, }; } @@ -551,7 +567,7 @@ export class VoiceSession { try { await this.host.reloadAgentSession(agentId, { systemPrompt: buildVoiceModeSystemPrompt(baseConfig.systemPrompt, false), - voiceToolMcpServerName: baseConfig.voiceToolMcpServerName, + extra: baseConfig.extra, }); } catch (error) { this.sessionLogger.warn( From f411d2c4b8877b081854be90a77c38ae98075e99 Mon Sep 17 00:00:00 2001 From: Martin Troiber Date: Thu, 23 Jul 2026 08:16:18 +0000 Subject: [PATCH 08/13] Restore voice MCP config helper test --- .../server/session.voice-mcp-config.test.ts | 27 +++++++++++++++++++ packages/server/src/server/voice-config.ts | 19 +++++++++++++ 2 files changed, 46 insertions(+) diff --git a/packages/server/src/server/session.voice-mcp-config.test.ts b/packages/server/src/server/session.voice-mcp-config.test.ts index 00742afde5..c7805d069c 100644 --- a/packages/server/src/server/session.voice-mcp-config.test.ts +++ b/packages/server/src/server/session.voice-mcp-config.test.ts @@ -1,11 +1,38 @@ import { describe, expect, test } from "vitest"; import { + buildVoiceAgentMcpServerConfig, buildVoiceModeSystemPrompt, stripVoiceModeSystemPrompt, wrapSpokenInput, } from "./voice-config.js"; +describe("voice MCP stdio config", () => { + test("builds stdio MCP config for voice agent", () => { + const config = buildVoiceAgentMcpServerConfig({ + command: "/usr/local/bin/node", + baseArgs: ["/tmp/mcp-stdio-socket-bridge-cli.mjs"], + socketPath: "/tmp/paseo-voice.sock", + env: { + ELECTRON_RUN_AS_NODE: "1", + PASEO_HOME: "/tmp/paseo-home", + }, + }); + + expect(config.type).toBe("stdio"); + expect(config.command).toBe("/usr/local/bin/node"); + expect(config.args).toEqual([ + "/tmp/mcp-stdio-socket-bridge-cli.mjs", + "--socket", + "/tmp/paseo-voice.sock", + ]); + expect(config.env).toEqual({ + ELECTRON_RUN_AS_NODE: "1", + PASEO_HOME: "/tmp/paseo-home", + }); + }); +}); + describe("voice mode prompt instructions", () => { test("builds enabled voice instructions and preserves base prompt", () => { const prompt = buildVoiceModeSystemPrompt("Base system prompt", true); diff --git a/packages/server/src/server/voice-config.ts b/packages/server/src/server/voice-config.ts index dbc5be68cb..25c3dd6fae 100644 --- a/packages/server/src/server/voice-config.ts +++ b/packages/server/src/server/voice-config.ts @@ -56,3 +56,22 @@ export function buildVoiceModeSystemPrompt(existing: string | undefined, enabled export function wrapSpokenInput(text: string): string { return `\n${text}\n\nThis message was spoken by the user. Respond using the speak tool only, not normal messages, because the user may not be looking at the chat.`; } + +export function buildVoiceAgentMcpServerConfig(params: { + command: string; + baseArgs: string[]; + socketPath: string; + env?: Record; +}): { + type: "stdio"; + command: string; + args: string[]; + env?: Record; +} { + return { + type: "stdio", + command: params.command, + args: [...params.baseArgs, "--socket", params.socketPath], + ...(params.env ? { env: params.env } : {}), + }; +} From 6ea3e219aedcd28b3b4af11755d8c9e24c00c5b4 Mon Sep 17 00:00:00 2001 From: Martin Troiber Date: Thu, 23 Jul 2026 09:35:55 +0000 Subject: [PATCH 09/13] Type Codex MCP server policies --- packages/server/src/server/agent/agent-sdk-types.ts | 13 ++++++++++++- .../agent/providers/codex-app-server-agent.ts | 12 ++---------- .../src/server/session/voice/voice-session.ts | 10 ++++------ 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 6836cd8bc4..56ef63ba0b 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -11,6 +11,17 @@ export interface AgentMetadata { [key: string]: unknown; } +export interface CodexMcpServerPolicy { + enabledTools?: string[]; + disabledTools?: string[]; + defaultToolsApprovalMode?: "auto" | "prompt" | "approve"; + tools?: Record; +} + +export interface CodexAgentExtra extends AgentMetadata { + mcpServerPolicies?: Record; +} + /** * Stdio-based MCP server (spawns a subprocess). */ @@ -573,7 +584,7 @@ export interface AgentSessionConfig { networkAccess?: boolean; webSearch?: boolean; extra?: { - codex?: AgentMetadata; + codex?: CodexAgentExtra; claude?: Partial; }; mcpServers?: Record; 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 1d7e0bc022..a1da803255 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 @@ -4,6 +4,7 @@ import { type AgentCapabilityFlags, type AgentClient, type AgentCreateSessionOptions, + type CodexMcpServerPolicy, type AgentFeature, type AgentLaunchContext, type AgentMode, @@ -798,13 +799,6 @@ interface CodexMcpServerConfig { >; } -interface CodexMcpServerPolicy { - enabledTools?: string[]; - disabledTools?: string[]; - defaultToolsApprovalMode?: "auto" | "prompt" | "approve"; - tools?: Record; -} - function toCodexMcpConfig( config: McpServerConfig, policy?: CodexMcpServerPolicy, @@ -4550,9 +4544,7 @@ export class CodexAppServerAgentSession implements AgentSession { private buildCodexInnerConfig(): Record | null { const innerConfig: Record = {}; const codexExtra = this.config.extra?.codex; - const mcpServerPolicies = codexExtra?.mcpServerPolicies as - | Record - | undefined; + const mcpServerPolicies = codexExtra?.mcpServerPolicies; if (this.config.mcpServers) { const mcpServers: Record = {}; for (const [name, serverConfig] of Object.entries(this.config.mcpServers)) { diff --git a/packages/server/src/server/session/voice/voice-session.ts b/packages/server/src/server/session/voice/voice-session.ts index 6983a0ef8d..5900b16b02 100644 --- a/packages/server/src/server/session/voice/voice-session.ts +++ b/packages/server/src/server/session/voice/voice-session.ts @@ -21,7 +21,7 @@ import { } from "../../voice-config.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "../../voice-types.js"; import type { ManagedAgent } from "../../agent/agent-manager.js"; -import type { AgentSessionConfig } from "../../agent/agent-sdk-types.js"; +import type { AgentSessionConfig, CodexMcpServerPolicy } from "../../agent/agent-sdk-types.js"; import type { LocalSpeechModelId } from "../../speech/providers/local/models.js"; import { toResolver, type Resolvable } from "../../speech/provider-resolver.js"; import type { SpeechReadinessSnapshot, SpeechReadinessState } from "../../speech/speech-runtime.js"; @@ -35,11 +35,11 @@ const MIN_STREAMING_SEGMENT_BYTES = Math.round( PCM_BYTES_PER_MS * MIN_STREAMING_SEGMENT_DURATION_MS, ); const AgentIdSchema = z.guid(); -const CODEX_VOICE_MCP_POLICY = { +const CODEX_VOICE_MCP_POLICY: CodexMcpServerPolicy = { enabledTools: ["speak"], defaultToolsApprovalMode: "prompt", tools: { speak: { approvalMode: "approve" } }, -} as const; +}; type ProcessingPhase = "idle" | "transcribing"; @@ -540,9 +540,7 @@ export class VoiceSession { codex: { ...existingConfig.extra?.codex, mcpServerPolicies: { - ...(existingConfig.extra?.codex?.mcpServerPolicies as - | Record - | undefined), + ...existingConfig.extra?.codex?.mcpServerPolicies, [PASEO_MCP_SERVER_NAME]: CODEX_VOICE_MCP_POLICY, }, }, From f292c3d5f07a383a47494cc6510d1eb50cb35432 Mon Sep 17 00:00:00 2001 From: Martin Troiber Date: Thu, 23 Jul 2026 09:51:05 +0000 Subject: [PATCH 10/13] Use native Codex MCP policy fields --- .../src/server/agent/agent-manager.test.ts | 12 ++++++------ .../server/src/server/agent/agent-sdk-types.ts | 8 ++++---- .../providers/codex-app-server-agent.test.ts | 6 +++--- .../agent/providers/codex-app-server-agent.ts | 18 +----------------- .../server/session/voice/voice-session.test.ts | 6 +++--- .../src/server/session/voice/voice-session.ts | 6 +++--- 6 files changed, 20 insertions(+), 36 deletions(-) diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index fc912db66f..3a0b207d5a 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -3784,9 +3784,9 @@ test("Codex voice MCP sessions use the generic paseo MCP injection with voice ap codex: { mcpServerPolicies: { [PASEO_MCP_SERVER_NAME]: { - enabledTools: ["speak"], - defaultToolsApprovalMode: "prompt", - tools: { speak: { approvalMode: "approve" } }, + enabled_tools: ["speak"], + default_tools_approval_mode: "prompt", + tools: { speak: { approval_mode: "approve" } }, }, }, }, @@ -3799,9 +3799,9 @@ test("Codex voice MCP sessions use the generic paseo MCP injection with voice ap expect(client.createdConfigs).toHaveLength(1); expect(client.createdConfigs[0]?.extra?.codex?.mcpServerPolicies).toMatchObject({ [PASEO_MCP_SERVER_NAME]: { - enabledTools: ["speak"], - defaultToolsApprovalMode: "prompt", - tools: { speak: { approvalMode: "approve" } }, + enabled_tools: ["speak"], + default_tools_approval_mode: "prompt", + tools: { speak: { approval_mode: "approve" } }, }, }); expect(client.createdConfigs[0]?.mcpServers).toMatchObject({ diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 56ef63ba0b..a145192a8e 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -12,10 +12,10 @@ export interface AgentMetadata { } export interface CodexMcpServerPolicy { - enabledTools?: string[]; - disabledTools?: string[]; - defaultToolsApprovalMode?: "auto" | "prompt" | "approve"; - tools?: Record; + enabled_tools?: string[]; + disabled_tools?: string[]; + default_tools_approval_mode?: "auto" | "prompt" | "approve"; + tools?: Record; } export interface CodexAgentExtra extends AgentMetadata { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 228d6318da..e6c30ee701 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -1092,9 +1092,9 @@ describe("Codex app-server provider", () => { codex: { mcpServerPolicies: { paseo: { - enabledTools: ["speak"], - defaultToolsApprovalMode: "prompt", - tools: { speak: { approvalMode: "approve" } }, + enabled_tools: ["speak"], + default_tools_approval_mode: "prompt", + tools: { speak: { approval_mode: "approve" } }, }, }, }, 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 a1da803255..a074b3e2e4 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 @@ -803,23 +803,7 @@ function toCodexMcpConfig( config: McpServerConfig, policy?: CodexMcpServerPolicy, ): CodexMcpServerConfig { - const base = { - ...(policy?.enabledTools ? { enabled_tools: policy.enabledTools } : {}), - ...(policy?.disabledTools ? { disabled_tools: policy.disabledTools } : {}), - ...(policy?.defaultToolsApprovalMode - ? { default_tools_approval_mode: policy.defaultToolsApprovalMode } - : {}), - ...(policy?.tools - ? { - tools: Object.fromEntries( - Object.entries(policy.tools).map(([toolName, toolConfig]) => [ - toolName, - toolConfig.approvalMode ? { approval_mode: toolConfig.approvalMode } : {}, - ]), - ), - } - : {}), - } satisfies Partial; + const base: Partial = policy ?? {}; switch (config.type) { case "stdio": diff --git a/packages/server/src/server/session/voice/voice-session.test.ts b/packages/server/src/server/session/voice/voice-session.test.ts index f2780f708a..53f0b5741d 100644 --- a/packages/server/src/server/session/voice/voice-session.test.ts +++ b/packages/server/src/server/session/voice/voice-session.test.ts @@ -127,9 +127,9 @@ test("Codex voice mode adds and restores its MCP policy in provider-specific con model_provider: "custom", mcpServerPolicies: { paseo: { - enabledTools: ["speak"], - defaultToolsApprovalMode: "prompt", - tools: { speak: { approvalMode: "approve" } }, + enabled_tools: ["speak"], + default_tools_approval_mode: "prompt", + tools: { speak: { approval_mode: "approve" } }, }, }, }, diff --git a/packages/server/src/server/session/voice/voice-session.ts b/packages/server/src/server/session/voice/voice-session.ts index 5900b16b02..99890503fe 100644 --- a/packages/server/src/server/session/voice/voice-session.ts +++ b/packages/server/src/server/session/voice/voice-session.ts @@ -36,9 +36,9 @@ const MIN_STREAMING_SEGMENT_BYTES = Math.round( ); const AgentIdSchema = z.guid(); const CODEX_VOICE_MCP_POLICY: CodexMcpServerPolicy = { - enabledTools: ["speak"], - defaultToolsApprovalMode: "prompt", - tools: { speak: { approvalMode: "approve" } }, + enabled_tools: ["speak"], + default_tools_approval_mode: "prompt", + tools: { speak: { approval_mode: "approve" } }, }; type ProcessingPhase = "idle" | "transcribing"; From b37532f2fee4d5e7ec69ee270bb1e01076dafbcb Mon Sep 17 00:00:00 2001 From: Martin Troiber Date: Thu, 23 Jul 2026 09:54:43 +0000 Subject: [PATCH 11/13] Reuse Codex MCP approval mode type --- packages/server/src/server/agent/agent-sdk-types.ts | 6 ++++-- .../src/server/agent/providers/codex-app-server-agent.ts | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index a145192a8e..6a65149b68 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -11,11 +11,13 @@ export interface AgentMetadata { [key: string]: unknown; } +export type CodexMcpApprovalMode = "auto" | "prompt" | "approve"; + export interface CodexMcpServerPolicy { enabled_tools?: string[]; disabled_tools?: string[]; - default_tools_approval_mode?: "auto" | "prompt" | "approve"; - tools?: Record; + default_tools_approval_mode?: CodexMcpApprovalMode; + tools?: Record; } export interface CodexAgentExtra extends AgentMetadata { 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 a074b3e2e4..91d3c78495 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 @@ -4,6 +4,7 @@ import { type AgentCapabilityFlags, type AgentClient, type AgentCreateSessionOptions, + type CodexMcpApprovalMode, type CodexMcpServerPolicy, type AgentFeature, type AgentLaunchContext, @@ -790,11 +791,11 @@ interface CodexMcpServerConfig { tool_timeout_sec?: number; enabled_tools?: string[]; disabled_tools?: string[]; - default_tools_approval_mode?: "auto" | "prompt" | "approve"; + default_tools_approval_mode?: CodexMcpApprovalMode; tools?: Record< string, { - approval_mode?: "auto" | "prompt" | "approve"; + approval_mode?: CodexMcpApprovalMode; } >; } From 953f7c3b573299d341d12d8b3af12397f9c427f9 Mon Sep 17 00:00:00 2001 From: Martin Troiber Date: Thu, 23 Jul 2026 10:06:07 +0000 Subject: [PATCH 12/13] Refresh Codex MCP policy test docs --- packages/server/src/server/agent/agent-manager.test.ts | 3 +-- packages/server/src/server/agent/agent-sdk-types.ts | 1 + .../src/server/agent/providers/codex-app-server-agent.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 3a0b207d5a..91e7dea58d 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -3765,7 +3765,7 @@ test("fetchTimeline returns a bounded reset window when cursor epoch is stale", expect(older.hasOlder).toBe(true); }); -test("Codex voice MCP sessions use the generic paseo MCP injection with voice approval", async () => { +test("Codex voice MCP launch config preserves voice approval and injects Paseo MCP", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-codex-voice-mcp-")); const storage = new AgentStorage(join(workdir, "agents"), logger); const client = new TestAgentClient(); @@ -3807,7 +3807,6 @@ test("Codex voice MCP sessions use the generic paseo MCP injection with voice ap expect(client.createdConfigs[0]?.mcpServers).toMatchObject({ [PASEO_MCP_SERVER_NAME]: { type: "http" }, }); - expect(client.createdConfigs[0]?.mcpServers?.[PASEO_MCP_SERVER_NAME]?.type).toBe("http"); expect(client.createdConfigs[0]?.mcpServers?.[PASEO_MCP_SERVER_NAME]).toHaveProperty( "url", expect.stringMatching(/^http:\/\/127\.0\.0\.1:4123\/mcp\/agents\?callerAgentId=[0-9a-f-]+$/), diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 6a65149b68..6e19822d31 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -13,6 +13,7 @@ export interface AgentMetadata { export type CodexMcpApprovalMode = "auto" | "prompt" | "approve"; +/** Native Codex MCP server settings stored under the Codex-specific extra config. */ export interface CodexMcpServerPolicy { enabled_tools?: string[]; disabled_tools?: string[]; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index e6c30ee701..3bc8c9e960 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -1080,7 +1080,7 @@ describe("Codex app-server provider", () => { }); }); - test("maps MCP approval settings into Codex inner config", () => { + test("includes native MCP approval settings in Codex inner config", () => { const session = createSession({ mcpServers: { paseo: { From 2e9c9af806df8822770deaadece642a1729a23d8 Mon Sep 17 00:00:00 2001 From: Martin Troiber Date: Thu, 23 Jul 2026 12:51:07 +0000 Subject: [PATCH 13/13] Address review comments for Codex voice providers --- .../session/voice/voice-session.test.ts | 24 +++++++++++++++++++ .../src/server/session/voice/voice-session.ts | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/session/voice/voice-session.test.ts b/packages/server/src/server/session/voice/voice-session.test.ts index 53f0b5741d..2717d6f7b0 100644 --- a/packages/server/src/server/session/voice/voice-session.test.ts +++ b/packages/server/src/server/session/voice/voice-session.test.ts @@ -145,6 +145,30 @@ test("Codex voice mode adds and restores its MCP policy in provider-specific con await voiceSession.cleanup(); }); +test("Codex-derived provider IDs receive the voice MCP policy", async () => { + const { voiceSession, host } = createVoiceSession({ + provider: "custom-codex", + cwd: "/tmp", + }); + + await voiceSession.handleSetVoiceMode(true, VOICE_AGENT_ID); + + expect(host.reloadedConfigs[0]).toMatchObject({ + extra: { + codex: { + mcpServerPolicies: { + paseo: { + enabled_tools: ["speak"], + default_tools_approval_mode: "prompt", + tools: { speak: { approval_mode: "approve" } }, + }, + }, + }, + }, + }); + await voiceSession.cleanup(); +}); + async function settle(): Promise { await Promise.resolve(); await Promise.resolve(); diff --git a/packages/server/src/server/session/voice/voice-session.ts b/packages/server/src/server/session/voice/voice-session.ts index 99890503fe..8665a65601 100644 --- a/packages/server/src/server/session/voice/voice-session.ts +++ b/packages/server/src/server/session/voice/voice-session.ts @@ -530,7 +530,7 @@ export class VoiceSession { private buildCodexVoiceModeOverrides( existingConfig: AgentSessionConfig, ): Partial | null { - if (existingConfig.provider !== "codex") { + if (!existingConfig.provider.toLowerCase().includes("codex")) { return null; }