diff --git a/packages/server/src/server/agent/activity-curator.test.ts b/packages/server/src/server/agent/activity-curator.test.ts index a0e10b615..dad730222 100644 --- a/packages/server/src/server/agent/activity-curator.test.ts +++ b/packages/server/src/server/agent/activity-curator.test.ts @@ -328,5 +328,37 @@ describe("curateAgentActivity", () => { expect(result).toBe("[Grep] TODO"); }); + + test("shows speak tool text input", () => { + const timeline: AgentTimelineItem[] = [ + { + type: "tool_call", + callId: "s1", + name: "speak", + input: { text: "hello from voice" }, + status: "completed", + }, + ]; + + const result = curateAgentActivity(timeline); + expect(result).toBe('[speak] {"text":"hello from voice"}'); + }); + + test("shows MCP tool input JSON", () => { + const timeline: AgentTimelineItem[] = [ + { + type: "tool_call", + callId: "m1", + name: "paseo__create_agent", + input: { cwd: "/tmp/repo", initialPrompt: "do the thing" }, + status: "completed", + }, + ]; + + const result = curateAgentActivity(timeline); + expect(result).toBe( + '[paseo__create_agent] {"cwd":"/tmp/repo","initialPrompt":"do the thing"}' + ); + }); }); }); diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts index 806ad6e2e..5c8425c33 100644 --- a/packages/server/src/server/agent/activity-curator.ts +++ b/packages/server/src/server/agent/activity-curator.ts @@ -2,6 +2,7 @@ import type { AgentTimelineItem } from "./agent-sdk-types.js"; import { extractPrincipalParam } from "../../utils/tool-call-parsers.js"; const DEFAULT_MAX_ITEMS = 40; +const MAX_TOOL_INPUT_CHARS = 400; function appendText(buffer: string, text: string): string { const normalized = text.trim(); @@ -25,6 +26,34 @@ function flushBuffers(lines: string[], buffers: { message: string; thought: stri buffers.thought = ""; } +function isLikelyMcpToolCall(name: string): boolean { + const normalized = name.toLowerCase(); + return ( + normalized.includes("mcp") || + normalized.includes("paseo") || + normalized.includes("__") || + normalized === "speak" + ); +} + +function formatToolInputJson(input: unknown): string | null { + if (input === undefined) { + return null; + } + try { + const encoded = JSON.stringify(input); + if (!encoded) { + return null; + } + if (encoded.length <= MAX_TOOL_INPUT_CHARS) { + return encoded; + } + return `${encoded.slice(0, MAX_TOOL_INPUT_CHARS)}...`; + } catch { + return null; + } +} + /** * Collapse timeline items: * - Dedupe tool calls by callId (pending/completed -> single) @@ -127,6 +156,11 @@ export function curateAgentActivity( break; case "tool_call": { flushBuffers(lines, buffers); + const inputJson = formatToolInputJson(item.input); + if (isLikelyMcpToolCall(item.name) && inputJson) { + lines.push(`[${item.name}] ${inputJson}`); + break; + } const principal = extractPrincipalParam(item.input); if (principal) { lines.push(`[${item.name}] ${principal}`); diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index ce7186091..65a044174 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it, vi } from "vitest"; +import { mkdtemp, mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; import { createTestLogger } from "../../test-utils/test-logger.js"; import { createAgentMcpServer } from "./mcp-server.js"; @@ -48,6 +51,7 @@ function createTestDeps(): TestDeps { describe("create_agent MCP tool", () => { const logger = createTestLogger(); + const existingCwd = process.cwd(); it("requires a concise title no longer than 60 characters", async () => { const { agentManager, agentStorage } = createTestDeps(); @@ -56,7 +60,7 @@ describe("create_agent MCP tool", () => { expect(tool).toBeDefined(); const missingTitle = await tool.inputSchema.safeParseAsync({ - cwd: "/tmp/repo", + cwd: existingCwd, initialMode: "default", initialPrompt: "test", }); @@ -64,7 +68,7 @@ describe("create_agent MCP tool", () => { expect(missingTitle.error.issues[0].path).toEqual(["title"]); const tooLong = await tool.inputSchema.safeParseAsync({ - cwd: "/tmp/repo", + cwd: existingCwd, initialMode: "default", title: "x".repeat(61), initialPrompt: "test", @@ -73,7 +77,7 @@ describe("create_agent MCP tool", () => { expect(tooLong.error.issues[0].path).toEqual(["title"]); const ok = await tool.inputSchema.safeParseAsync({ - cwd: "/tmp/repo", + cwd: existingCwd, initialMode: "default", title: "Short title", initialPrompt: "test", @@ -86,7 +90,7 @@ describe("create_agent MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); const tool = (server as any)._registeredTools["create_agent"]; const parsed = await tool.inputSchema.safeParseAsync({ - cwd: "/tmp/repo", + cwd: existingCwd, initialMode: "default", title: "Short title", }); @@ -94,6 +98,20 @@ describe("create_agent MCP tool", () => { expect(parsed.error.issues.some((issue: { path: string[] }) => issue.path[0] === "initialPrompt")).toBe(true); }); + it("fails immediately when cwd does not exist", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const tool = (server as any)._registeredTools["create_agent"]; + + await expect( + tool.callback({ + cwd: "/path/that/does/not/exist", + title: "Short title", + initialPrompt: "Do work", + }) + ).rejects.toThrow("Working directory does not exist"); + }); + it("passes caller-provided titles directly into createAgent", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.createAgent.mockResolvedValue({ @@ -108,14 +126,14 @@ describe("create_agent MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); const tool = (server as any)._registeredTools["create_agent"]; await tool.callback({ - cwd: "/tmp/repo", + cwd: existingCwd, title: " Fix auth bug ", initialPrompt: "Do work", }); expect(spies.agentManager.createAgent).toHaveBeenCalledWith( expect.objectContaining({ - cwd: "/tmp/repo", + cwd: existingCwd, title: "Fix auth bug", }), undefined, @@ -137,7 +155,7 @@ describe("create_agent MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); const tool = (server as any)._registeredTools["create_agent"]; await tool.callback({ - cwd: "/tmp/repo", + cwd: existingCwd, title: " Fix auth ", initialPrompt: "Do work", }); @@ -153,15 +171,18 @@ describe("create_agent MCP tool", () => { it("allows caller agents to override cwd and applies caller context labels", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); + const baseDir = await mkdtemp(join(tmpdir(), "paseo-mcp-test-")); + const subdir = join(baseDir, "subdir"); + await mkdir(subdir, { recursive: true }); spies.agentManager.getAgent.mockReturnValue({ id: "voice-agent", - cwd: "/tmp/voice", + cwd: baseDir, provider: "codex", currentModeId: "full-access", } as ManagedAgent); spies.agentManager.createAgent.mockResolvedValue({ id: "child-agent", - cwd: "/tmp/voice/subdir", + cwd: subdir, lifecycle: "idle", currentModeId: null, availableModes: [], @@ -189,11 +210,12 @@ describe("create_agent MCP tool", () => { expect(spies.agentManager.createAgent).toHaveBeenCalledWith( expect.objectContaining({ - cwd: "/tmp/voice/subdir", + cwd: subdir, }), undefined, { labels: { ui: "true" } } ); + await rm(baseDir, { recursive: true, force: true }); }); }); diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 83d09a9ab..41c69f68f 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -28,6 +28,7 @@ import { AgentStorage } from "./agent-storage.js"; import { createWorktree } from "../../utils/worktree.js"; import { WaitForAgentTracker } from "./wait-for-agent-tracker.js"; import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js"; +import { validateWorkingDirectoryExists } from "./working-directory-validation.js"; export interface AgentMcpServerOptions { agentManager: AgentManager; @@ -496,6 +497,7 @@ export async function createAgentMcpServer( } = topLevelArgs; resolvedCwd = expandPath(cwd); + await validateWorkingDirectoryExists(resolvedCwd); if (worktreeName) { if (!baseBranch) { @@ -513,6 +515,7 @@ export async function createAgentMcpServer( resolvedMode = initialMode; } + await validateWorkingDirectoryExists(resolvedCwd); const provider: AgentProvider = agentType ?? "claude"; const normalizedTitle = title?.trim() ?? null; diff --git a/packages/server/src/server/agent/working-directory-validation.ts b/packages/server/src/server/agent/working-directory-validation.ts new file mode 100644 index 000000000..ac6a567d6 --- /dev/null +++ b/packages/server/src/server/agent/working-directory-validation.ts @@ -0,0 +1,20 @@ +import { stat } from "node:fs/promises"; + +export async function validateWorkingDirectoryExists( + cwd: string +): Promise { + try { + const cwdStats = await stat(cwd); + if (!cwdStats.isDirectory()) { + throw new Error(`Working directory is not a directory: ${cwd}`); + } + } catch (error) { + if (error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error(`Working directory does not exist: ${cwd}`); + } + if (error instanceof Error) { + throw error; + } + throw new Error(`Failed to access working directory: ${cwd}`); + } +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index c245db56a..1863b77fe 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -63,6 +63,7 @@ import { AgentManager } from "./agent/agent-manager.js"; import type { ManagedAgent } from "./agent/agent-manager.js"; import { scheduleAgentMetadataGeneration } from "./agent/agent-metadata-generator.js"; import { toAgentPayload } from "./agent/agent-projections.js"; +import { validateWorkingDirectoryExists } from "./agent/working-directory-validation.js"; import { StructuredAgentResponseError, generateStructuredAgentResponse, @@ -298,6 +299,7 @@ export class Session { // Voice mode state private isVoiceMode = false; private speechInProgress = false; + // OpenRouter voice-only conversation storage identifier. private voiceConversationId: string | null = null; private readonly dictationStreamManager: DictationStreamManager; @@ -1400,6 +1402,16 @@ export class Session { } this.isVoiceMode = true; + if (this.voiceLlmProvider !== "openrouter") { + this.voiceConversationId = null; + this.voiceAssistantAgentId = voiceConversationId; + this.sessionLogger.info( + { voiceAssistantAgentId: this.voiceAssistantAgentId }, + "Voice conversation enabled (agent-backed)" + ); + return; + } + this.voiceConversationId = voiceConversationId; const loaded = await this.voiceConversationStore.load( @@ -1416,6 +1428,14 @@ export class Session { } this.isVoiceMode = false; + if (this.voiceLlmProvider !== "openrouter") { + this.sessionLogger.info( + { voiceAssistantAgentId: this.voiceAssistantAgentId }, + "Voice conversation disabled (agent-backed)" + ); + return; + } + const idToPersist = this.voiceConversationId; if (idToPersist) { try { @@ -1548,21 +1568,7 @@ export class Session { try { // Validate that the working directory exists const resolvedCwd = expandTilde(config.cwd); - try { - const stats = await stat(resolvedCwd); - if (!stats.isDirectory()) { - throw new Error( - `Working directory is not a directory: ${config.cwd}` - ); - } - } catch (statError: any) { - if (statError.code === "ENOENT") { - throw new Error( - `Working directory does not exist: ${config.cwd}` - ); - } - throw statError; - } + await validateWorkingDirectoryExists(resolvedCwd); const { sessionConfig, worktreeConfig } = await this.buildAgentSessionConfig( config, @@ -4653,11 +4659,20 @@ export class Session { if (existing) { return existing.id; } - this.voiceAssistantAgentId = null; + try { + const hydrated = await this.ensureAgentLoaded(this.voiceAssistantAgentId); + this.voiceAssistantAgentId = hydrated.id; + return hydrated.id; + } catch (error) { + this.sessionLogger.debug( + { err: error, voiceAssistantAgentId: this.voiceAssistantAgentId }, + "Voice assistant agent not found in active/persisted state; creating new session" + ); + } } const provider = this.resolveVoiceAgentProvider(); - const voiceAgentId = `voice-${uuidv4()}`; + const voiceAgentId = this.voiceAssistantAgentId ?? uuidv4(); const cwd = join(this.paseoHome, "voice-agent-workspace"); await mkdir(cwd, { recursive: true }); diff --git a/packages/server/src/utils/tool-call-parsers.ts b/packages/server/src/utils/tool-call-parsers.ts index cc320b27b..a8cf81df0 100644 --- a/packages/server/src/utils/tool-call-parsers.ts +++ b/packages/server/src/utils/tool-call-parsers.ts @@ -28,6 +28,7 @@ const PrincipalParamSchema = z.union([ z.object({ pattern: z.string() }).transform((d) => ({ type: "text" as const, value: d.pattern })), z.object({ query: z.string() }).transform((d) => ({ type: "text" as const, value: d.query })), z.object({ url: z.string() }).transform((d) => ({ type: "text" as const, value: d.url })), + z.object({ text: z.string() }).transform((d) => ({ type: "text" as const, value: d.text })), // Files array (Codex apply_patch) z.object({ files: z.array(FileEntrySchema).nonempty() }).transform((d) => ({ type: "path" as const, value: d.files[0].path })), // TodoWrite - show in_progress item or count