Unify cwd validation and improve MCP tool log visibility

This commit is contained in:
Mohamed Boudra
2026-02-06 16:21:41 +07:00
parent bc494501f6
commit 217f4b1cb5
7 changed files with 154 additions and 27 deletions

View File

@@ -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"}'
);
});
});
});

View File

@@ -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}`);

View File

@@ -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 });
});
});

View File

@@ -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;

View File

@@ -0,0 +1,20 @@
import { stat } from "node:fs/promises";
export async function validateWorkingDirectoryExists(
cwd: string
): Promise<void> {
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}`);
}
}

View File

@@ -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 });

View File

@@ -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