diff --git a/packages/app/e2e/checkout-ship.spec.ts b/packages/app/e2e/checkout-ship.spec.ts index 1fa9cb428..0c48ac8c2 100644 --- a/packages/app/e2e/checkout-ship.spec.ts +++ b/packages/app/e2e/checkout-ship.spec.ts @@ -95,34 +95,6 @@ async function waitForAssistantText(page: Page, text: string) { return assistantMessage; } -async function waitForAssistantTextWithPermissions( - page: Page, - text: string, - timeoutMs = 60000 -) { - const start = Date.now(); - const assistantMessage = page - .getByTestId('assistant-message') - .filter({ hasText: text }) - .last(); - while (Date.now() - start < timeoutMs) { - if (await assistantMessage.isVisible()) { - return assistantMessage; - } - const allowButton = page.getByText('Allow', { exact: true }).first(); - if (await allowButton.isVisible()) { - try { - await allowButton.click({ force: true, timeout: 1000 }); - } catch { - // Button can detach during animation; retry on next loop. - } - continue; - } - await page.waitForTimeout(500); - } - throw new Error(`Timed out waiting for assistant text: ${text}`); -} - async function createAgentAndWait(page: Page, message: string) { const input = page.getByRole('textbox', { name: 'Message agent...' }); await expect(input).toBeEditable(); @@ -250,18 +222,8 @@ test('checkout-first Changes panel ship loop', async ({ page }) => { const secondCwd = await requestCwd(page); expect(secondCwd).toBe(firstCwd); - await sendPrompt( - page, - 'Only call MCP tools set_title("E2E Ship Loop") and set_branch("feat/e2e-ship-loop"). Do not run bash or other tools. Then respond with exactly: OK' - ); - await waitForAssistantTextWithPermissions(page, 'OK', 60000); - await expect(page.getByText('E2E Ship Loop', { exact: true }).first()).toBeVisible(); - - await openChangesPanel(page); - await expect.poll( - async () => (await getChangesScope(page).getByTestId('changes-branch').innerText()).trim(), - { timeout: 60000 } - ).toBe('feat/e2e-ship-loop'); + await sendPrompt(page, "Respond with exactly: OK"); + await waitForAssistantText(page, "OK"); const readmePath = path.join(firstCwd, 'README.md'); await appendFile(readmePath, '\nFirst change\n'); diff --git a/packages/app/src/utils/tool-call-parsers.ts b/packages/app/src/utils/tool-call-parsers.ts index f2bb24339..07a832851 100644 --- a/packages/app/src/utils/tool-call-parsers.ts +++ b/packages/app/src/utils/tool-call-parsers.ts @@ -1163,8 +1163,6 @@ const TOOL_NAME_MAP: Record = { read_file: "Read", apply_patch: "Edit", paseo_worktree_setup: "Setup", - set_title: "Set title", - set_branch: "Set branch", thinking: "Thinking", }; diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 7f0c5bb90..cee6d72f6 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,6 +1,4 @@ import { Command } from 'commander' -import { homedir } from 'node:os' -import { join } from 'node:path' import { createAgentCommand } from './commands/agent/index.js' import { createDaemonCommand } from './commands/daemon/index.js' import { createPermitCommand } from './commands/permit/index.js' @@ -15,7 +13,6 @@ import { runInspectCommand } from './commands/agent/inspect.js' import { runWaitCommand } from './commands/agent/wait.js' import { runAttachCommand } from './commands/agent/attach.js' import { withOutput } from './output/index.js' -import { runSelfIdBridge } from '@paseo/server/self-id-bridge' const VERSION = '0.1.0' @@ -140,20 +137,5 @@ export function createCli(): Command { // Worktree commands program.addCommand(createWorktreeCommand()) - // Self-ID bridge command (for internal use by agents to call set_title/set_branch) - program - .command('self-id-bridge') - .description('Stdio-to-HTTP bridge for Agent Self-ID MCP (internal use)') - .option('--socket ', 'Unix socket path', join(process.env.PASEO_HOME ?? join(homedir(), '.paseo'), 'self-id-mcp.sock')) - .option('--agent-id ', 'Caller agent ID') - .option('--debug', 'Enable debug logging to stderr') - .action(async (options) => { - await runSelfIdBridge({ - socketPath: options.socket, - agentId: options.agentId, - debug: options.debug, - }) - }) - return program } diff --git a/packages/server/package.json b/packages/server/package.json index 0ad19f39b..430c3e1db 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -5,8 +5,7 @@ "type": "module", "exports": { ".": "./src/server/exports.ts", - "./utils/tool-call-parsers": "./src/utils/tool-call-parsers.ts", - "./self-id-bridge": "./src/self-id-bridge/index.ts" + "./utils/tool-call-parsers": "./src/utils/tool-call-parsers.ts" }, "scripts": { "dev": "NODE_ENV=development tsx scripts/dev-runner.ts", diff --git a/packages/server/src/self-id-bridge/index.ts b/packages/server/src/self-id-bridge/index.ts deleted file mode 100644 index 524e71839..000000000 --- a/packages/server/src/self-id-bridge/index.ts +++ /dev/null @@ -1,275 +0,0 @@ -/** - * Agent Self-ID Bridge - * - * Bridges stdio MCP transport to HTTP-over-Unix-socket transport. - * This allows coding agents (which only support stdio or HTTP MCP) to - * call set_title and set_branch on the Paseo daemon. - * - * Architecture: - * Coding Agent (Claude Code / Codex) - * | - * | stdio (newline-delimited JSON-RPC) - * v - * paseo self-id-bridge (this module) - * | - * | HTTP over Unix socket (${PASEO_HOME}/self-id-mcp.sock) - * v - * Paseo Daemon (Agent Self-ID MCP Server) - */ - -import { createInterface } from "node:readline"; -import http from "node:http"; - -export interface SelfIdBridgeOptions { - socketPath: string; - agentId?: string; - debug?: boolean; -} - -interface JsonRpcRequest { - jsonrpc: "2.0"; - method: string; - params?: unknown; - id?: string | number | null; -} - -interface JsonRpcResponse { - jsonrpc: "2.0"; - result?: unknown; - error?: { code: number; message: string; data?: unknown }; - id: string | number | null; -} - -function log(debug: boolean, ...args: unknown[]): void { - if (debug) { - console.error("[self-id-bridge]", ...args); - } -} - -function makeHttpRequest( - socketPath: string, - urlPath: string, - body: string, - headers: Record -): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> { - return new Promise((resolve, reject) => { - const req = http.request( - { - socketPath, - path: urlPath, - method: "POST", - headers: { - "Content-Type": "application/json", - "Content-Length": Buffer.byteLength(body), - ...headers, - }, - }, - (res) => { - const chunks: Buffer[] = []; - res.on("data", (chunk) => chunks.push(chunk)); - res.on("end", () => { - resolve({ - status: res.statusCode ?? 500, - headers: res.headers, - body: Buffer.concat(chunks).toString("utf-8"), - }); - }); - res.on("error", reject); - } - ); - req.on("error", reject); - req.write(body); - req.end(); - }); -} - -function writeResponse(response: JsonRpcResponse): void { - const line = JSON.stringify(response); - process.stdout.write(line + "\n"); -} - -function writeError(id: string | number | null, code: number, message: string): void { - writeResponse({ - jsonrpc: "2.0", - error: { code, message }, - id, - }); -} - -export async function runSelfIdBridge(options: SelfIdBridgeOptions): Promise { - const { socketPath, agentId, debug = false } = options; - - log(debug, `Starting Self-ID bridge to ${socketPath}`); - if (agentId) { - log(debug, `Agent ID: ${agentId}`); - } - - let mcpSessionId: string | null = null; - let protocolVersion: string | null = null; - - const rl = createInterface({ - input: process.stdin, - crlfDelay: Infinity, - }); - - for await (const line of rl) { - if (!line.trim()) { - continue; - } - - let request: JsonRpcRequest; - try { - request = JSON.parse(line); - } catch { - log(debug, "Failed to parse JSON:", line); - writeError(null, -32700, "Parse error"); - continue; - } - - log(debug, "Request:", request.method, request.id); - - // Build headers - const headers: Record = { - "Accept": "application/json, text/event-stream", - }; - if (mcpSessionId) { - headers["mcp-session-id"] = mcpSessionId; - } - if (protocolVersion && request.method !== "initialize") { - headers["mcp-protocol-version"] = protocolVersion; - } - - // Build URL with callerAgentId if provided - let path = "/"; - if (agentId) { - path = `/?callerAgentId=${encodeURIComponent(agentId)}`; - } - - try { - const response = await makeHttpRequest( - socketPath, - path, - JSON.stringify(request), - headers - ); - - log(debug, "Response status:", response.status); - - // Check for session ID in response headers - const newSessionId = response.headers["mcp-session-id"]; - if (typeof newSessionId === "string" && newSessionId !== mcpSessionId) { - mcpSessionId = newSessionId; - log(debug, "Session ID:", mcpSessionId); - } - - // Handle content type - const contentType = response.headers["content-type"] ?? ""; - - if (contentType.includes("text/event-stream")) { - // SSE response - parse events and write each as a line - const events = parseSSE(response.body); - for (const event of events) { - if (event.data) { - process.stdout.write(event.data + "\n"); - } - } - } else { - // JSON response - write as-is - const jsonResponse = JSON.parse(response.body) as JsonRpcResponse; - - // Extract protocol version from initialize response - if (request.method === "initialize" && jsonResponse.result) { - const result = jsonResponse.result as { protocolVersion?: string }; - if (result.protocolVersion) { - protocolVersion = result.protocolVersion; - log(debug, "Protocol version:", protocolVersion); - } - } - - writeResponse(jsonResponse); - } - } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - log(debug, "HTTP error:", message); - - // Check if it's a connection error - if (message.includes("ENOENT") || message.includes("ECONNREFUSED")) { - writeError( - request.id ?? null, - -32603, - `Paseo daemon unreachable at ${socketPath}. Is the daemon running?` - ); - } else { - writeError(request.id ?? null, -32603, `Internal error: ${message}`); - } - } - } - - log(debug, "stdin closed, exiting"); -} - -interface SSEEvent { - event?: string; - data?: string; - id?: string; -} - -function parseSSE(body: string): SSEEvent[] { - const events: SSEEvent[] = []; - let currentEvent: SSEEvent = {}; - let dataLines: string[] = []; - - for (const line of body.split("\n")) { - if (line === "") { - // End of event - if (dataLines.length > 0) { - currentEvent.data = dataLines.join("\n"); - } - if (Object.keys(currentEvent).length > 0) { - events.push(currentEvent); - } - currentEvent = {}; - dataLines = []; - continue; - } - - if (line.startsWith(":")) { - // Comment, ignore - continue; - } - - const colonIndex = line.indexOf(":"); - if (colonIndex === -1) { - // Field with no value - continue; - } - - const field = line.slice(0, colonIndex); - let value = line.slice(colonIndex + 1); - if (value.startsWith(" ")) { - value = value.slice(1); - } - - switch (field) { - case "event": - currentEvent.event = value; - break; - case "data": - dataLines.push(value); - break; - case "id": - currentEvent.id = value; - break; - } - } - - // Handle final event if no trailing newline - if (dataLines.length > 0) { - currentEvent.data = dataLines.join("\n"); - } - if (Object.keys(currentEvent).length > 0) { - events.push(currentEvent); - } - - return events; -} diff --git a/packages/server/src/server/agent/agent-management-mcp.ts b/packages/server/src/server/agent/agent-management-mcp.ts index a52004fc3..e2e26d0b6 100644 --- a/packages/server/src/server/agent/agent-management-mcp.ts +++ b/packages/server/src/server/agent/agent-management-mcp.ts @@ -50,7 +50,7 @@ import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js"; import { AgentStorage } from "./agent-storage.js"; import { createWorktree } from "../../utils/worktree.js"; import { WaitForAgentTracker } from "./wait-for-agent-tracker.js"; -import { injectLeadingPaseoInstructionTag } from "./paseo-instructions-tag.js"; +import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js"; export interface AgentManagementMcpOptions { agentManager: AgentManager; @@ -345,16 +345,20 @@ export async function createAgentManagementMcpServer( title: normalizedTitle ?? undefined, }); - if (initialPrompt) { - const initialPromptWithInstructions = injectLeadingPaseoInstructionTag( - initialPrompt, - snapshot.config.paseoPromptInstructions - ); + const trimmedPrompt = initialPrompt?.trim(); + if (trimmedPrompt) { + scheduleAgentMetadataGeneration({ + agentManager, + agentId: snapshot.id, + cwd: snapshot.cwd, + initialPrompt: trimmedPrompt, + explicitTitle: normalizedTitle ?? undefined, + paseoHome: options.paseoHome, + logger: childLogger, + }); + try { - agentManager.recordUserMessage( - snapshot.id, - initialPromptWithInstructions - ); + agentManager.recordUserMessage(snapshot.id, trimmedPrompt); } catch (error) { childLogger.error( { err: error, agentId: snapshot.id }, @@ -363,12 +367,7 @@ export async function createAgentManagementMcpServer( } try { - startAgentRun( - agentManager, - snapshot.id, - initialPromptWithInstructions, - childLogger - ); + startAgentRun(agentManager, snapshot.id, trimmedPrompt, childLogger); if (!background) { const result = await waitForAgentWithTimeout( diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index c6a2c34b9..6ebb45e29 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -5,7 +5,6 @@ import { type AgentLifecycleStatus, } from "../../shared/agent-lifecycle.js"; import type { Logger } from "pino"; -import { getSelfIdentificationInstructions } from "./self-identification-instructions.js"; import type { AgentCapabilityFlags, @@ -59,8 +58,6 @@ export type AgentManagerOptions = { registry?: AgentStorage; onAgentAttention?: AgentAttentionCallback; logger: Logger; - /** Path to the Self-ID MCP Unix socket for UI agent injection */ - selfIdMcpSocketPath?: string; }; export type WaitForAgentOptions = { @@ -213,7 +210,6 @@ export class AgentManager { private readonly registry?: AgentStorage; private readonly previousStatuses = new Map(); private readonly backgroundTasks = new Set>(); - private readonly selfIdMcpSocketPath?: string; private onAgentAttention?: AgentAttentionCallback; private logger: Logger; @@ -222,7 +218,6 @@ export class AgentManager { options?.maxTimelineItems ?? DEFAULT_MAX_TIMELINE_ITEMS; this.idFactory = options?.idFactory ?? (() => randomUUID()); this.registry = options?.registry; - this.selfIdMcpSocketPath = options?.selfIdMcpSocketPath; this.onAgentAttention = options?.onAgentAttention; this.logger = options.logger.child({ module: "agent", component: "agent-manager" }); if (options?.clients) { @@ -1261,7 +1256,7 @@ export class AgentManager { private async normalizeConfig( config: AgentSessionConfig, - options?: { labels?: Record; agentId?: string } + _options?: { labels?: Record; agentId?: string } ): Promise { const normalized: AgentSessionConfig = { ...config }; @@ -1275,31 +1270,6 @@ export class AgentManager { normalized.model = trimmed.length > 0 ? trimmed : undefined; } - // Inject paseoPromptInstructions and MCP config for UI agents (with ui=true label) - const isUiAgent = options?.labels?.ui === "true"; - if (isUiAgent) { - normalized.paseoPromptInstructions = getSelfIdentificationInstructions({ - cwd: normalized.cwd, - }); - - // Inject Self-ID MCP server config (stdio bridge to self-id-mcp.sock) - if (this.selfIdMcpSocketPath && options?.agentId) { - const existingMcpServers = normalized.mcpServers ?? {}; - normalized.mcpServers = { - ...existingMcpServers, - "paseo-self-id": { - type: "stdio", - command: "paseo", - args: [ - "self-id-bridge", - "--socket", this.selfIdMcpSocketPath, - "--agent-id", options.agentId, - ], - }, - }; - } - } - return normalized; } diff --git a/packages/server/src/server/agent/agent-mcp.e2e.test.ts b/packages/server/src/server/agent/agent-mcp.e2e.test.ts index 08edb39e7..58e9ac5e6 100644 --- a/packages/server/src/server/agent/agent-mcp.e2e.test.ts +++ b/packages/server/src/server/agent/agent-mcp.e2e.test.ts @@ -83,7 +83,6 @@ describe("agent MCP end-to-end (offline)", () => { const daemonConfig: PaseoDaemonConfig = { listen: `127.0.0.1:${port}`, paseoHome, - selfIdMcpSocketPath: path.join(paseoHome, "self-id-mcp.sock"), corsAllowedOrigins: [], agentMcpRoute: "/mcp/agents", agentMcpAllowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`], @@ -150,4 +149,3 @@ describe("agent MCP end-to-end (offline)", () => { 30_000 ); }); - diff --git a/packages/server/src/server/agent/agent-metadata-generator.test.ts b/packages/server/src/server/agent/agent-metadata-generator.test.ts new file mode 100644 index 000000000..15062508f --- /dev/null +++ b/packages/server/src/server/agent/agent-metadata-generator.test.ts @@ -0,0 +1,148 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { execSync } from "child_process"; +import { mkdtempSync, rmSync, writeFileSync, realpathSync } from "fs"; +import { tmpdir } from "os"; +import path from "path"; +import pino from "pino"; + +import { AgentManager } from "./agent-manager.js"; +import { AgentStorage } from "./agent-storage.js"; +import { createAllClients, shutdownProviders } from "./provider-registry.js"; +import { generateAndApplyAgentMetadata } from "./agent-metadata-generator.js"; +import { createWorktree, validateBranchSlug } from "../../utils/worktree.js"; + +const CODEX_TEST_MODEL = "gpt-5.1-codex-mini"; +const CODEX_TEST_REASONING_EFFORT = "low"; + +function tmpCwd(prefix: string): string { + return realpathSync(mkdtempSync(path.join(tmpdir(), prefix))); +} + +function initGitRepo(repoDir: string): void { + execSync("git init -b main", { cwd: repoDir, stdio: "pipe" }); + execSync("git config user.email 'paseo-test@example.com'", { + cwd: repoDir, + stdio: "pipe", + }); + execSync("git config user.name 'Paseo Test'", { + cwd: repoDir, + stdio: "pipe", + }); + writeFileSync(path.join(repoDir, "README.md"), "init\n"); + execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", { + cwd: repoDir, + stdio: "pipe", + }); +} + +describe("agent metadata generation (real agents)", () => { + const logger = pino({ level: "silent" }); + let repoDir: string; + let paseoHome: string; + let storagePath: string; + let manager: AgentManager; + let storage: AgentStorage; + let codexSessionDir: string; + let previousCodexSessionDir: string | undefined; + + beforeEach(() => { + repoDir = tmpCwd("metadata-repo-"); + initGitRepo(repoDir); + paseoHome = tmpCwd("metadata-paseo-home-"); + storagePath = path.join(paseoHome, "agents"); + storage = new AgentStorage(storagePath, logger); + manager = new AgentManager({ + clients: createAllClients(logger), + registry: storage, + logger, + }); + codexSessionDir = tmpCwd("codex-sessions-"); + previousCodexSessionDir = process.env.CODEX_SESSION_DIR; + process.env.CODEX_SESSION_DIR = codexSessionDir; + }); + + afterEach(async () => { + process.env.CODEX_SESSION_DIR = previousCodexSessionDir; + await shutdownProviders(logger); + rmSync(repoDir, { recursive: true, force: true }); + rmSync(paseoHome, { recursive: true, force: true }); + rmSync(codexSessionDir, { recursive: true, force: true }); + }, 60000); + + test( + "generates a title using a real Codex agent", + async () => { + const agent = await manager.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + reasoningEffort: CODEX_TEST_REASONING_EFFORT, + modeId: "auto", + cwd: repoDir, + title: "Main Agent", + }, "metadata-title-agent"); + + await generateAndApplyAgentMetadata({ + agentManager: manager, + agentId: agent.id, + cwd: repoDir, + initialPrompt: "Use the exact title 'Metadata Title E2E'.", + explicitTitle: null, + paseoHome, + logger, + }); + + await storage.flush(); + const record = await storage.get(agent.id); + expect(record?.title).toBe("Metadata Title E2E"); + + await manager.closeAgent(agent.id); + }, + 180000 + ); + + test( + "renames the worktree branch using a real Codex agent", + async () => { + const worktreeSlug = "metadata-worktree"; + const worktree = await createWorktree({ + branchName: worktreeSlug, + cwd: repoDir, + baseBranch: "main", + worktreeSlug, + paseoHome, + }); + + const agent = await manager.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + reasoningEffort: CODEX_TEST_REASONING_EFFORT, + modeId: "auto", + cwd: worktree.worktreePath, + title: "Worktree Agent", + }, "metadata-branch-agent"); + + await generateAndApplyAgentMetadata({ + agentManager: manager, + agentId: agent.id, + cwd: worktree.worktreePath, + initialPrompt: "Use the exact branch 'feat/metadata-worktree'.", + explicitTitle: "Explicit Title", + paseoHome, + logger, + }); + + const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", { + cwd: worktree.worktreePath, + stdio: "pipe", + }).toString().trim(); + + const validation = validateBranchSlug(currentBranch); + expect(validation.valid).toBe(true); + expect(currentBranch).toBe("feat/metadata-worktree"); + + await manager.closeAgent(agent.id); + }, + 180000 + ); +}); diff --git a/packages/server/src/server/agent/agent-metadata-generator.ts b/packages/server/src/server/agent/agent-metadata-generator.ts new file mode 100644 index 000000000..5b6509d6a --- /dev/null +++ b/packages/server/src/server/agent/agent-metadata-generator.ts @@ -0,0 +1,248 @@ +import { basename } from "path"; +import { z } from "zod"; +import type { Logger } from "pino"; + +import type { AgentManager } from "./agent-manager.js"; +import { + StructuredAgentResponseError, + generateStructuredAgentResponse, +} from "./agent-response-loop.js"; +import { validateBranchSlug } from "../../utils/worktree.js"; +import { + getCheckoutStatus, + renameCurrentBranch, + type CheckoutStatusResult, +} from "../../utils/checkout-git.js"; + +const AUTO_GEN_PROVIDER = "codex" as const; +const AUTO_GEN_MODEL = "gpt-5.1-codex-mini"; +const AUTO_GEN_REASONING_EFFORT = "low"; + +export type AgentMetadataGeneratorDeps = { + generateStructuredAgentResponse?: typeof generateStructuredAgentResponse; + getCheckoutStatus?: typeof getCheckoutStatus; + renameCurrentBranch?: typeof renameCurrentBranch; +}; + +export type AgentMetadataGenerationOptions = { + agentManager: AgentManager; + agentId: string; + cwd: string; + initialPrompt?: string | null; + explicitTitle?: string | null; + paseoHome?: string; + logger: Logger; + deps?: AgentMetadataGeneratorDeps; +}; + +type AgentMetadataNeeds = { + prompt: string | null; + needsTitle: boolean; + needsBranch: boolean; +}; + +function hasExplicitTitle(title?: string | null): boolean { + return Boolean(title && title.trim().length > 0); +} + +async function canRenameBranch( + cwd: string, + paseoHome: string | undefined, + getCheckoutStatusImpl: typeof getCheckoutStatus +): Promise { + let status: CheckoutStatusResult; + try { + status = await getCheckoutStatusImpl(cwd, { paseoHome }); + } catch { + return false; + } + + if (!status.isGit || !status.isPaseoOwnedWorktree) { + return false; + } + + if (!status.currentBranch) { + return false; + } + + const worktreeDirName = basename(status.repoRoot); + return status.currentBranch === worktreeDirName; +} + +export async function determineAgentMetadataNeeds( + options: Pick +): Promise { + const prompt = options.initialPrompt?.trim(); + if (!prompt) { + return { prompt: null, needsTitle: false, needsBranch: false }; + } + + const needsTitle = !hasExplicitTitle(options.explicitTitle); + const getCheckoutStatusImpl = options.deps?.getCheckoutStatus ?? getCheckoutStatus; + const needsBranch = await canRenameBranch( + options.cwd, + options.paseoHome, + getCheckoutStatusImpl + ); + + return { + prompt, + needsTitle, + needsBranch, + }; +} + +function buildMetadataSchema(needs: AgentMetadataNeeds): z.ZodObject | null { + if (!needs.needsTitle && !needs.needsBranch) { + return null; + } + + const shape: Record = {}; + if (needs.needsTitle) { + shape.title = z.string().min(1).max(60); + } + if (needs.needsBranch) { + shape.branch = z.string().min(1).max(100); + } + return z.object(shape); +} + +function buildPrompt(needs: AgentMetadataNeeds): string { + const fields = [needs.needsTitle ? "title" : null, needs.needsBranch ? "branch" : null].filter( + Boolean + ) as string[]; + + const instructions: string[] = [ + "Generate metadata for a coding agent based on the user prompt.", + ]; + + if (needs.needsTitle) { + instructions.push("Title: short descriptive label (<= 60 chars)."); + } + if (needs.needsBranch) { + instructions.push( + "Branch: lowercase slug using letters, numbers, hyphens, and slashes only; no spaces, no uppercase, no leading/trailing hyphen, no consecutive hyphens." + ); + } + + if (fields.length === 1) { + instructions.push(`Return JSON only with a single field '${fields[0]}'.`); + } else { + instructions.push(`Return JSON only with fields '${fields.join("' and '")}'.`); + } + + instructions.push("", "User prompt:", needs.prompt ?? ""); + return instructions.join("\n"); +} + +export async function generateAndApplyAgentMetadata( + options: AgentMetadataGenerationOptions +): Promise { + const needs = await determineAgentMetadataNeeds(options); + if (!needs.prompt) { + return; + } + + const schema = buildMetadataSchema(needs); + if (!schema) { + return; + } + + const generator = options.deps?.generateStructuredAgentResponse ?? generateStructuredAgentResponse; + const getCheckoutStatusImpl = options.deps?.getCheckoutStatus ?? getCheckoutStatus; + const renameCurrentBranchImpl = options.deps?.renameCurrentBranch ?? renameCurrentBranch; + + let result: { title?: string; branch?: string }; + + try { + result = await generator({ + manager: options.agentManager, + agentConfig: { + provider: AUTO_GEN_PROVIDER, + model: AUTO_GEN_MODEL, + reasoningEffort: AUTO_GEN_REASONING_EFFORT, + cwd: options.cwd, + title: "Agent metadata generator", + internal: true, + }, + prompt: buildPrompt(needs), + schema, + schemaName: "AgentMetadata", + maxRetries: 2, + }); + } catch (error) { + if (error instanceof StructuredAgentResponseError) { + options.logger.warn( + { err: error, agentId: options.agentId }, + "Structured metadata generation failed" + ); + return; + } + options.logger.error( + { err: error, agentId: options.agentId }, + "Agent metadata generation failed" + ); + return; + } + + if (needs.needsTitle && typeof result.title === "string") { + const normalizedTitle = result.title.trim(); + if (normalizedTitle.length > 0) { + await options.agentManager.setTitle(options.agentId, normalizedTitle); + } + } + + if (needs.needsBranch && typeof result.branch === "string") { + const normalizedBranch = result.branch.trim(); + const validation = validateBranchSlug(normalizedBranch); + if (!validation.valid) { + options.logger.warn( + { agentId: options.agentId, branch: normalizedBranch, error: validation.error }, + "Generated branch name is invalid" + ); + return; + } + + let status: CheckoutStatusResult; + try { + status = await getCheckoutStatusImpl(options.cwd, { paseoHome: options.paseoHome }); + } catch (error) { + options.logger.warn( + { err: error, agentId: options.agentId }, + "Failed to re-check branch eligibility" + ); + return; + } + + if (!status.isGit || !status.isPaseoOwnedWorktree || !status.currentBranch) { + return; + } + + const worktreeDirName = basename(status.repoRoot); + if (status.currentBranch !== worktreeDirName) { + return; + } + + try { + await renameCurrentBranchImpl(options.cwd, normalizedBranch); + } catch (error) { + options.logger.warn( + { err: error, agentId: options.agentId, branch: normalizedBranch }, + "Failed to rename branch" + ); + } + } +} + +export function scheduleAgentMetadataGeneration( + options: AgentMetadataGenerationOptions +): void { + queueMicrotask(() => { + void generateAndApplyAgentMetadata(options).catch((error) => { + options.logger.error( + { err: error, agentId: options.agentId }, + "Agent metadata generation crashed" + ); + }); + }); +} diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 82fa7ab9a..4d2e1d622 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -234,14 +234,6 @@ export type AgentSessionConfig = { networkAccess?: boolean; webSearch?: boolean; reasoningEffort?: string; - /** - * Paseo-owned instructions injected into the first user prompt via - * .... - * - * These MUST NOT be sent via provider system/developer instructions (those are - * reserved for provider/session behaviors like resuming). - */ - paseoPromptInstructions?: string; extra?: { codex?: AgentMetadata; claude?: Partial; diff --git a/packages/server/src/server/agent/agent-self-id-mcp.ts b/packages/server/src/server/agent/agent-self-id-mcp.ts deleted file mode 100644 index deb39e75a..000000000 --- a/packages/server/src/server/agent/agent-self-id-mcp.ts +++ /dev/null @@ -1,204 +0,0 @@ -/** - * Agent Self-ID MCP Server - * - * Purpose: Agents identifying themselves (title, branch) - * Transport: Stdio bridge → Unix socket (${PASEO_HOME}/self-id-mcp.sock) - * Server name: "paseo-agent-self-id" - * - * Tools: - * - set_title - Set agent's display title - * - set_branch - Rename git branch (Paseo worktrees only) - * - * Requires callerAgentId - must know which agent is calling. - */ - -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { basename } from "path"; -import { z } from "zod"; -import { ensureValidJson } from "../json-utils.js"; -import type { Logger } from "pino"; - -import type { AgentManager } from "./agent-manager.js"; -import { - isPaseoOwnedWorktreeCwd, - validateBranchSlug, -} from "../../utils/worktree.js"; -import { - NotGitRepoError, - renameCurrentBranch, - getCheckoutStatus, -} from "../../utils/checkout-git.js"; - -export interface AgentSelfIdMcpOptions { - agentManager: AgentManager; - paseoHome?: string; - /** - * ID of the agent that is connecting to this MCP server. - * Required - this server only works for managed agents. - */ - callerAgentId: string; - logger: Logger; -} - -type ToolErrorCode = "NOT_ALLOWED" | "NOT_GIT_REPO" | "INVALID_BRANCH"; - -class AgentSelfIdToolError extends Error { - readonly code: ToolErrorCode; - - constructor(code: ToolErrorCode, message: string) { - super(message); - this.name = "AgentSelfIdToolError"; - this.code = code; - } -} - -export async function createAgentSelfIdMcpServer( - options: AgentSelfIdMcpOptions -): Promise { - const { agentManager, callerAgentId, logger } = options; - const childLogger = logger.child({ - module: "agent", - component: "agent-self-id-mcp", - callerAgentId, - }); - - const server = new McpServer({ - name: "paseo-agent-self-id", - version: "1.0.0", - }); - - server.registerTool( - "set_title", - { - title: "Set Agent Title", - description: "Update the agent's title in the registry.", - inputSchema: { - title: z - .string() - .min(1) - .max(60) - .describe("Short descriptive title (<= 60 chars)."), - }, - outputSchema: { - success: z.boolean(), - title: z.string(), - }, - }, - async ({ title }) => { - const agent = agentManager.getAgent(callerAgentId); - if (!agent) { - throw new Error(`Agent ${callerAgentId} not found`); - } - - const normalizedTitle = title.trim(); - if (!normalizedTitle) { - throw new AgentSelfIdToolError("NOT_ALLOWED", "Title cannot be empty"); - } - if (normalizedTitle.length > 60) { - throw new AgentSelfIdToolError( - "NOT_ALLOWED", - "Title must be 60 characters or fewer" - ); - } - - childLogger.debug({ title: normalizedTitle }, "Setting agent title"); - await agentManager.setTitle(agent.id, normalizedTitle); - - return { - content: [], - structuredContent: ensureValidJson({ - success: true, - title: normalizedTitle, - }), - }; - } - ); - - server.registerTool( - "set_branch", - { - title: "Set Agent Branch", - description: - "Rename the current git branch. Allowed only inside Paseo-owned worktrees.", - inputSchema: { - name: z - .string() - .min(1) - .describe( - "Git branch name (lowercase letters, numbers, hyphens, slashes)." - ), - }, - outputSchema: { - success: z.boolean(), - branch: z.string(), - }, - }, - async ({ name }) => { - const agent = agentManager.getAgent(callerAgentId); - if (!agent) { - throw new Error(`Agent ${callerAgentId} not found`); - } - - const validation = validateBranchSlug(name); - if (!validation.valid) { - throw new AgentSelfIdToolError( - "INVALID_BRANCH", - validation.error ?? "Invalid branch name" - ); - } - - let ownership; - try { - ownership = await isPaseoOwnedWorktreeCwd(agent.cwd, { - paseoHome: options.paseoHome, - }); - } catch (error) { - const notGitError = - error instanceof NotGitRepoError - ? error - : new NotGitRepoError(agent.cwd); - throw new AgentSelfIdToolError("NOT_GIT_REPO", notGitError.message); - } - - if (!ownership.allowed) { - throw new AgentSelfIdToolError( - "NOT_ALLOWED", - "Branch renames are only allowed inside Paseo-owned worktrees" - ); - } - - const status = await getCheckoutStatus(agent.cwd, { - paseoHome: options.paseoHome, - }); - if (!status.isGit || !status.currentBranch) { - throw new AgentSelfIdToolError("NOT_GIT_REPO", "Unable to determine current branch"); - } - - const worktreeDirName = basename(status.repoRoot); - if (status.currentBranch !== worktreeDirName) { - throw new AgentSelfIdToolError( - "NOT_ALLOWED", - "Branch has already been renamed. Use git commands for subsequent renames." - ); - } - - childLogger.debug({ branch: name }, "Renaming branch"); - const result = await renameCurrentBranch(agent.cwd, name); - if (result.currentBranch !== name) { - throw new Error( - `Branch rename failed (expected ${name}, got ${result.currentBranch ?? "unknown"})` - ); - } - - return { - content: [], - structuredContent: ensureValidJson({ - success: true, - branch: name, - }), - }; - } - ); - - return server; -} diff --git a/packages/server/src/server/agent/agent-self-identification.e2e.test.ts b/packages/server/src/server/agent/agent-self-identification.e2e.test.ts deleted file mode 100644 index 87f8bdbc2..000000000 --- a/packages/server/src/server/agent/agent-self-identification.e2e.test.ts +++ /dev/null @@ -1,359 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { execSync } from "node:child_process"; -import { randomUUID } from "node:crypto"; - -import { createTestLogger } from "../../test-utils/test-logger.js"; -import { AgentManager } from "./agent-manager.js"; -import { AgentStorage } from "./agent-storage.js"; -import { createAgentSelfIdMcpServer } from "./agent-self-id-mcp.js"; -import { createWorktree } from "../../utils/worktree.js"; -import type { - AgentClient, - AgentRunResult, - AgentSession, - AgentSessionConfig, - AgentStreamEvent, -} from "./agent-sdk-types.js"; - -const TEST_CAPABILITIES = { - supportsStreaming: false, - supportsSessionPersistence: false, - supportsDynamicModes: false, - supportsMcpServers: false, - supportsReasoningStream: false, - supportsToolInvocations: false, -} as const; - -class TestAgentClient implements AgentClient { - readonly provider = "codex" as const; - readonly capabilities = TEST_CAPABILITIES; - - async createSession(config: AgentSessionConfig): Promise { - return new TestAgentSession(config); - } - - async resumeSession(config?: Partial): Promise { - return new TestAgentSession({ - provider: "codex", - cwd: config?.cwd ?? process.cwd(), - }); - } -} - -class TestAgentSession implements AgentSession { - readonly provider = "codex" as const; - readonly capabilities = TEST_CAPABILITIES; - readonly id = randomUUID(); - - constructor(private readonly config: AgentSessionConfig) {} - - async run(): Promise { - return { - sessionId: this.id ?? this.config.provider, - finalText: "", - timeline: [], - }; - } - - async *stream(): AsyncGenerator { - yield { type: "turn_started", provider: this.provider }; - yield { type: "turn_completed", provider: this.provider }; - } - - async *streamHistory(): AsyncGenerator {} - - async getRuntimeInfo() { - return { - provider: this.provider, - sessionId: this.id, - model: this.config.model ?? null, - modeId: this.config.modeId ?? null, - }; - } - - async getAvailableModes() { - return []; - } - - async getCurrentMode() { - return null; - } - - async setMode(): Promise {} - - getPendingPermissions() { - return []; - } - - async respondToPermission(): Promise {} - - describePersistence() { - return { - provider: this.provider, - sessionId: this.id, - }; - } - - async interrupt(): Promise {} - - async close(): Promise {} -} - -function initGitRepo(repoDir: string): void { - execSync("git init -b main", { cwd: repoDir, stdio: "ignore" }); - execSync('git config user.email "paseo-test@example.com"', { - cwd: repoDir, - stdio: "ignore", - }); - execSync('git config user.name "Paseo Test"', { - cwd: repoDir, - stdio: "ignore", - }); - writeFileSync(path.join(repoDir, "README.md"), "init\n"); - execSync("git add README.md", { cwd: repoDir, stdio: "ignore" }); - execSync('git commit -m "init"', { cwd: repoDir, stdio: "ignore" }); -} - -describe("self-identification MCP tools", () => { - const logger = createTestLogger(); - - test("set_branch renames branch for Paseo worktree", async () => { - const repoDir = mkdtempSync(path.join(tmpdir(), "paseo-self-ident-")); - const paseoHome = path.join(repoDir, "paseo-home"); - - try { - initGitRepo(repoDir); - const worktree = await createWorktree({ - branchName: "self-ident", - cwd: repoDir, - baseBranch: "main", - worktreeSlug: "self-ident", - paseoHome, - }); - - const storagePath = path.join(repoDir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const manager = new AgentManager({ - clients: { codex: new TestAgentClient() }, - registry: storage, - logger, - idFactory: () => "agent-self-ident", - }); - - const agent = await manager.createAgent({ - provider: "codex", - cwd: worktree.worktreePath, - }); - - const server = await createAgentSelfIdMcpServer({ - agentManager: manager, - paseoHome, - callerAgentId: agent.id, - logger, - }); - const tool = (server as any)._registeredTools["set_branch"]; - - await tool.callback({ name: "self-ident-ready" }); - - const branch = execSync("git rev-parse --abbrev-ref HEAD", { - cwd: worktree.worktreePath, - stdio: "pipe", - }) - .toString() - .trim(); - - expect(branch).toBe("self-ident-ready"); - } finally { - rmSync(repoDir, { recursive: true, force: true }); - } - }); - - test("set_branch allows agents running in a subdirectory of a Paseo worktree", async () => { - const repoDir = mkdtempSync(path.join(tmpdir(), "paseo-self-ident-")); - const paseoHome = path.join(repoDir, "paseo-home"); - - try { - initGitRepo(repoDir); - const worktree = await createWorktree({ - branchName: "self-ident-subdir", - cwd: repoDir, - baseBranch: "main", - worktreeSlug: "self-ident-subdir", - paseoHome, - }); - const nestedDir = path.join(worktree.worktreePath, "nested"); - execSync(`mkdir -p "${nestedDir}"`, { stdio: "ignore" }); - - const storagePath = path.join(repoDir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const manager = new AgentManager({ - clients: { codex: new TestAgentClient() }, - registry: storage, - logger, - idFactory: () => "agent-self-ident-subdir", - }); - - const agent = await manager.createAgent({ - provider: "codex", - cwd: nestedDir, - }); - - const server = await createAgentSelfIdMcpServer({ - agentManager: manager, - paseoHome, - callerAgentId: agent.id, - logger, - }); - const tool = (server as any)._registeredTools["set_branch"]; - - await tool.callback({ name: "self-ident-subdir-ready" }); - - const branch = execSync("git rev-parse --abbrev-ref HEAD", { - cwd: nestedDir, - stdio: "pipe", - }) - .toString() - .trim(); - - expect(branch).toBe("self-ident-subdir-ready"); - } finally { - rmSync(repoDir, { recursive: true, force: true }); - } - }); - - test("set_branch rejects subsequent renames after initial rename", async () => { - const repoDir = mkdtempSync(path.join(tmpdir(), "paseo-self-ident-")); - const paseoHome = path.join(repoDir, "paseo-home"); - - try { - initGitRepo(repoDir); - const worktree = await createWorktree({ - branchName: "initial-branch", - cwd: repoDir, - baseBranch: "main", - worktreeSlug: "initial-branch", - paseoHome, - }); - - const storagePath = path.join(repoDir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const manager = new AgentManager({ - clients: { codex: new TestAgentClient() }, - registry: storage, - logger, - idFactory: () => "agent-subsequent-rename", - }); - - const agent = await manager.createAgent({ - provider: "codex", - cwd: worktree.worktreePath, - }); - - const server = await createAgentSelfIdMcpServer({ - agentManager: manager, - paseoHome, - callerAgentId: agent.id, - logger, - }); - const tool = (server as any)._registeredTools["set_branch"]; - - // First rename should succeed - await tool.callback({ name: "first-rename" }); - - const branchAfterFirst = execSync("git rev-parse --abbrev-ref HEAD", { - cwd: worktree.worktreePath, - stdio: "pipe", - }) - .toString() - .trim(); - expect(branchAfterFirst).toBe("first-rename"); - - // Second rename should fail - await expect(tool.callback({ name: "second-rename" })).rejects.toMatchObject({ - code: "NOT_ALLOWED", - message: expect.stringContaining("already been renamed"), - }); - - // Branch should still be first-rename - const branchAfterSecond = execSync("git rev-parse --abbrev-ref HEAD", { - cwd: worktree.worktreePath, - stdio: "pipe", - }) - .toString() - .trim(); - expect(branchAfterSecond).toBe("first-rename"); - } finally { - rmSync(repoDir, { recursive: true, force: true }); - } - }); - - test("set_branch rejects non-Paseo checkouts", async () => { - const repoDir = mkdtempSync(path.join(tmpdir(), "paseo-self-ident-")); - - try { - initGitRepo(repoDir); - const storagePath = path.join(repoDir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const manager = new AgentManager({ - clients: { codex: new TestAgentClient() }, - registry: storage, - logger, - idFactory: () => "agent-non-worktree", - }); - - const agent = await manager.createAgent({ - provider: "codex", - cwd: repoDir, - }); - - const server = await createAgentSelfIdMcpServer({ - agentManager: manager, - callerAgentId: agent.id, - logger, - }); - const tool = (server as any)._registeredTools["set_branch"]; - - await expect(tool.callback({ name: "should-fail" })).rejects.toMatchObject({ - code: "NOT_ALLOWED", - }); - } finally { - rmSync(repoDir, { recursive: true, force: true }); - } - }); - - test("set_branch rejects non-git directories", async () => { - const repoDir = mkdtempSync(path.join(tmpdir(), "paseo-self-ident-")); - - try { - const storagePath = path.join(repoDir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const manager = new AgentManager({ - clients: { codex: new TestAgentClient() }, - registry: storage, - logger, - idFactory: () => "agent-non-git", - }); - - const agent = await manager.createAgent({ - provider: "codex", - cwd: repoDir, - }); - - const server = await createAgentSelfIdMcpServer({ - agentManager: manager, - callerAgentId: agent.id, - logger, - }); - const tool = (server as any)._registeredTools["set_branch"]; - - await expect(tool.callback({ name: "nope" })).rejects.toMatchObject({ - code: "NOT_GIT_REPO", - }); - } finally { - rmSync(repoDir, { recursive: true, force: true }); - } - }); -}); diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index ef9e9fa17..afbfbbf64 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vitest"; import { createTestLogger } from "../../test-utils/test-logger.js"; import { createAgentMcpServer } from "./mcp-server.js"; -import { createAgentSelfIdMcpServer } from "./agent-self-id-mcp.js"; import type { AgentManager, ManagedAgent } from "./agent-manager.js"; import type { AgentStorage } from "./agent-storage.js"; @@ -104,24 +103,27 @@ describe("create_agent MCP tool", () => { ); }); - it("set_title trims and persists titles for caller agent", async () => { - const { agentManager, spies } = createTestDeps(); - spies.agentManager.getAgent.mockReturnValue({ - id: "agent-1", + it("trims caller-provided titles before createAgent", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + spies.agentManager.createAgent.mockResolvedValue({ + id: "agent-456", + cwd: "/tmp/repo", + lifecycle: "idle", + currentModeId: null, + availableModes: [], } as ManagedAgent); - const server = await createAgentSelfIdMcpServer({ - agentManager, - logger, - callerAgentId: "agent-1", + const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const tool = (server as any)._registeredTools["create_agent"]; + await tool.callback({ + cwd: "/tmp/repo", + title: " Fix auth ", }); - const tool = (server as any)._registeredTools["set_title"]; - await tool.callback({ title: " Fix auth " }); - - expect(spies.agentManager.setTitle).toHaveBeenCalledWith( - "agent-1", - "Fix auth" + expect(spies.agentManager.createAgent).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Fix auth", + }) ); }); }); diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index afe8e05d9..ee7d4d70a 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -27,7 +27,7 @@ import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js"; import { AgentStorage } from "./agent-storage.js"; import { createWorktree } from "../../utils/worktree.js"; import { WaitForAgentTracker } from "./wait-for-agent-tracker.js"; -import { injectLeadingPaseoInstructionTag } from "./paseo-instructions-tag.js"; +import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js"; export interface AgentMcpServerOptions { agentManager: AgentManager; @@ -440,13 +440,20 @@ export async function createAgentMcpServer( title: normalizedTitle ?? undefined, }); - if (initialPrompt) { - const initialPromptWithInstructions = injectLeadingPaseoInstructionTag( - initialPrompt, - snapshot.config.paseoPromptInstructions - ); + const trimmedPrompt = initialPrompt?.trim(); + if (trimmedPrompt) { + scheduleAgentMetadataGeneration({ + agentManager, + agentId: snapshot.id, + cwd: snapshot.cwd, + initialPrompt: trimmedPrompt, + explicitTitle: snapshot.config.title, + paseoHome: options.paseoHome, + logger: childLogger, + }); + try { - agentManager.recordUserMessage(snapshot.id, initialPromptWithInstructions); + agentManager.recordUserMessage(snapshot.id, trimmedPrompt); } catch (error) { childLogger.error( { err: error, agentId: snapshot.id }, @@ -455,7 +462,7 @@ export async function createAgentMcpServer( } try { - startAgentRun(agentManager, snapshot.id, initialPromptWithInstructions, childLogger); + startAgentRun(agentManager, snapshot.id, trimmedPrompt, childLogger); // If not running in background, wait for completion if (!background) { diff --git a/packages/server/src/server/agent/paseo-instructions-tag.test.ts b/packages/server/src/server/agent/paseo-instructions-tag.test.ts deleted file mode 100644 index 09512de3a..000000000 --- a/packages/server/src/server/agent/paseo-instructions-tag.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, test } from "vitest"; - -import { - formatPaseoInstructionTag, - hasLeadingPaseoInstructionTag, - injectLeadingPaseoInstructionTag, - stripLeadingPaseoInstructionTag, -} from "./paseo-instructions-tag.js"; - -describe("paseo instruction tags", () => { - test("formatPaseoInstructionTag wraps content", () => { - expect(formatPaseoInstructionTag("hello")).toBe( - "\nhello\n" - ); - }); - - test("hasLeadingPaseoInstructionTag detects leading tag", () => { - expect(hasLeadingPaseoInstructionTag("\nX\n")).toBe( - true - ); - expect(hasLeadingPaseoInstructionTag("nope ")).toBe(false); - }); - - test("stripLeadingPaseoInstructionTag strips leading tag content", () => { - const input = [ - "", - "do the thing", - "", - "", - "Hello world", - ].join("\n"); - expect(stripLeadingPaseoInstructionTag(input)).toBe("Hello world"); - }); - - test("injectLeadingPaseoInstructionTag prepends instructions when missing", () => { - expect(injectLeadingPaseoInstructionTag("Hello", "do the thing")).toBe( - "\ndo the thing\n\n\nHello" - ); - }); - - test("injectLeadingPaseoInstructionTag is idempotent when tag already present", () => { - const input = "\nX\n\n\nHello"; - expect(injectLeadingPaseoInstructionTag(input, "do the thing")).toBe(input); - }); -}); diff --git a/packages/server/src/server/agent/paseo-instructions-tag.ts b/packages/server/src/server/agent/paseo-instructions-tag.ts deleted file mode 100644 index 548d27b4c..000000000 --- a/packages/server/src/server/agent/paseo-instructions-tag.ts +++ /dev/null @@ -1,48 +0,0 @@ -const OPEN_TAG = ""; -const CLOSE_TAG = ""; - -export function formatPaseoInstructionTag(instructions: string): string { - return `${OPEN_TAG}\n${instructions}\n${CLOSE_TAG}`; -} - -export function hasLeadingPaseoInstructionTag(text: string): boolean { - return /^\s*/.test(text); -} - -/** - * Prepend paseo instructions to a prompt exactly once (idempotent by content). - * This is intended for agent creation / initial prompt only. - */ -export function injectLeadingPaseoInstructionTag( - prompt: string, - instructions: string | null | undefined -): string { - const normalizedInstructions = instructions?.trim() ?? ""; - if (!normalizedInstructions) { - return prompt; - } - if (hasLeadingPaseoInstructionTag(prompt)) { - return prompt; - } - return `${formatPaseoInstructionTag(normalizedInstructions)}\n\n${prompt}`; -} - -/** - * Remove a leading ... block, if present. - * The content is treated as internal metadata and is discarded. - */ -export function stripLeadingPaseoInstructionTag(text: string): string { - const leadingMatch = text.match(/^\s*/); - if (!leadingMatch || leadingMatch.index !== 0) { - return text; - } - - const openEnd = leadingMatch[0].length; - const closeStart = text.indexOf(CLOSE_TAG, openEnd); - if (closeStart === -1) { - return text; - } - - const closeEnd = closeStart + CLOSE_TAG.length; - return text.slice(closeEnd).trimStart(); -} 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 a2250e42f..8b0eed924 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 @@ -147,8 +147,6 @@ describe("Codex app-server provider (integration)", () => { cwd, modeId: "auto", approvalPolicy: "on-request", - paseoPromptInstructions: - "You must use the shell tool for command execution tasks. Do not answer without running the command.", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT, }); @@ -229,8 +227,6 @@ describe("Codex app-server provider (integration)", () => { cwd, modeId: "full-access", approvalPolicy: "on-request", - paseoPromptInstructions: - "You must use shell for commands and apply_patch for file edits. Do not skip tool usage.", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT, }); @@ -552,8 +548,6 @@ describe("Codex app-server provider (integration)", () => { cwd, modeId: "full-access", approvalPolicy: "on-request", - paseoPromptInstructions: - "You must use the apply_patch tool for file edits. Do not use the shell tool for file changes.", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT, }); @@ -616,9 +610,6 @@ describe("Codex app-server provider (integration)", () => { expect(sawPermissionResolved).toBe(true); } expect(sawPermission || timelineItems.length > 0).toBe(true); - expect( - timelineItems.some((item) => item.type === "tool_call" && item.name === "apply_patch") - ).toBe(true); expect(readFileSync(targetPath, "utf8").trim()).toBe("ok"); } finally { cleanup(); 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 8e1fed0f7..a123cec95 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 @@ -33,7 +33,6 @@ import os from "node:os"; import path from "node:path"; import readline from "node:readline"; -import { injectLeadingPaseoInstructionTag } from "../paseo-instructions-tag.js"; const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000; const CODEX_PROVIDER = "codex" as const; @@ -1335,37 +1334,15 @@ class CodexAppServerAgentSession implements AgentSession { private buildUserInput(prompt: AgentPromptInput): unknown[] { if (typeof prompt === "string") { - const text = this.paseoInstructionsInjected - ? prompt - : injectLeadingPaseoInstructionTag(prompt, this.config.paseoPromptInstructions); this.paseoInstructionsInjected = true; - return [{ type: "text", text }]; + return [{ type: "text", text: prompt }]; } const blocks = prompt as AgentPromptContentBlock[]; if (this.paseoInstructionsInjected) { return blocks; } this.paseoInstructionsInjected = true; - if (blocks.length === 0) { - return blocks; - } - const first = blocks[0]; - if (first && typeof first === "object" && (first as { type?: string }).type === "text") { - const textBlock = first as { type: "text"; text: string }; - const text = injectLeadingPaseoInstructionTag( - textBlock.text ?? "", - this.config.paseoPromptInstructions - ); - return [{ ...textBlock, text }, ...blocks.slice(1)]; - } - const injected = injectLeadingPaseoInstructionTag( - "", - this.config.paseoPromptInstructions - ); - if (injected.trim().length === 0) { - return blocks; - } - return [{ type: "text", text: injected }, ...blocks]; + return blocks; } private emitEvent(event: AgentStreamEvent): void { diff --git a/packages/server/src/server/agent/providers/codex-mcp-agent.paseo-instructions.test.ts b/packages/server/src/server/agent/providers/codex-mcp-agent.paseo-instructions.test.ts deleted file mode 100644 index fb0bd8cfb..000000000 --- a/packages/server/src/server/agent/providers/codex-mcp-agent.paseo-instructions.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { mkdtempSync, writeFileSync } from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { __test__ } from "./codex-mcp-agent.js"; -import type { AgentSessionConfig } from "../agent-sdk-types.js"; - -describe("codex developer-instructions vs paseo prompt instructions", () => { - test("does not inject Paseo self-identification into developer-instructions", () => { - const dir = mkdtempSync(path.join(os.tmpdir(), "codex-rollout-")); - const rolloutPath = path.join(dir, "rollout.jsonl"); - - const entry = { - type: "response_item", - payload: { - type: "message", - role: "user", - content: [{ input_text: "hello from history" }], - }, - }; - - writeFileSync(rolloutPath, JSON.stringify(entry) + "\n", "utf8"); - - const config: AgentSessionConfig = { - provider: "codex", - cwd: dir, - modeId: "auto", - }; - - const payload = __test__.buildCodexMcpConfig( - config, - "Hello world", - "auto", - undefined, - rolloutPath - ); - - const dev = payload["developer-instructions"] ?? ""; - expect(dev).toContain(""); - expect(dev).toContain("hello from history"); - expect(dev.toLowerCase()).not.toContain("set_title"); - expect(dev.toLowerCase()).not.toContain("set_branch"); - expect(dev.toLowerCase()).not.toContain("you are running under paseo"); - }); -}); - diff --git a/packages/server/src/server/agent/providers/codex-mcp-agent.test.ts b/packages/server/src/server/agent/providers/codex-mcp-agent.test.ts deleted file mode 100644 index d3d3127f8..000000000 --- a/packages/server/src/server/agent/providers/codex-mcp-agent.test.ts +++ /dev/null @@ -1,1596 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { execFileSync } from "node:child_process"; -import { randomUUID } from "node:crypto"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { z } from "zod"; - -import { createTestLogger } from "../../../test-utils/test-logger.js"; -import type { - AgentPermissionRequest, - AgentSession, - AgentSessionConfig, - AgentStreamEvent, - AgentTimelineItem, -} from "../agent-sdk-types.js"; - -type ToolCallItem = Extract; - -// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution -const CODEX_TEST_MODEL = "gpt-5.1-codex-mini"; -const CODEX_TEST_REASONING_EFFORT = "low"; - -function tmpCwd(): string { - return mkdtempSync(path.join(os.tmpdir(), "codex-mcp-e2e-")); -} - -function useTempCodexSessionDir(): () => void { - const prevSessionDir = process.env.CODEX_SESSION_DIR; - const prevHome = process.env.CODEX_HOME; - return () => { - if (prevSessionDir === undefined) { - delete process.env.CODEX_SESSION_DIR; - } else { - process.env.CODEX_SESSION_DIR = prevSessionDir; - } - if (prevHome === undefined) { - delete process.env.CODEX_HOME; - } else { - process.env.CODEX_HOME = prevHome; - } - }; -} - -function listProcesses(): string[] { - try { - const output = execFileSync("ps", ["-ax", "-o", "pid=,command="], { - encoding: "utf8", - }); - return output - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0); - } catch { - return []; - } -} - -function isProcessRunning(marker: string): boolean { - return listProcesses().some((line) => line.includes(marker)); -} - -async function waitForProcessExit(marker: string, timeoutMs: number): Promise { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - if (!isProcessRunning(marker)) { - return true; - } - await new Promise((resolve) => setTimeout(resolve, 100)); - } - return !isProcessRunning(marker); -} - -function writeTestMcpServerScript(cwd: string): string { - const scriptPath = path.join(cwd, "mcp-stdio-server.mjs"); - const nodeModulesPath = resolveNodeModulesPath(); - const requireBase = nodeModulesPath - ? path.join(nodeModulesPath, "..", "package.json") - : null; - const importLines = requireBase - ? [ - "import { createRequire } from 'node:module';", - `const require = createRequire(${JSON.stringify(requireBase)});`, - "const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');", - "const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');", - "const { z } = require('zod');", - ] - : [ - "import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';", - "import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';", - "import { z } from 'zod';", - ]; - const script = [ - ...importLines, - "", - "const server = new McpServer({ name: 'test', version: '0.0.1' });", - "server.registerTool(", - " 'echo',", - " {", - " title: 'Echo tool',", - " description: 'Returns the input text',", - " inputSchema: { text: z.string() },", - " outputSchema: { text: z.string() }", - " },", - " async ({ text }) => ({", - " content: [],", - " structuredContent: { text }", - " })", - ");", - "server.registerTool(", - " 'todo_list',", - " {", - " title: 'Todo list tool',", - " description: 'Returns the requested todo list items',", - " inputSchema: { items: z.array(z.string()) },", - " outputSchema: { items: z.array(z.string()) }", - " },", - " async ({ items }) => ({", - " content: [],", - " structuredContent: { items }", - " })", - ");", - "const transport = new StdioServerTransport();", - "await server.connect(transport);", - "", - ].join("\n"); - writeFileSync(scriptPath, script, "utf8"); - return scriptPath; -} - -async function loadCodexMcpAgentClient(): Promise { - try { - return await import("./codex-mcp-agent.js"); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error( - `Failed to import codex-mcp-agent: ${message}` - ); - } -} - -function providerFromEvent(event: AgentStreamEvent): string | undefined { - return event.provider; -} - -function resolveNodeModulesPath(): string | null { - const candidates = [ - path.join(process.cwd(), "node_modules"), - path.join(process.cwd(), "..", "node_modules"), - path.join(process.cwd(), "..", "..", "node_modules"), - ]; - for (const candidate of candidates) { - if (existsSync(candidate)) { - return candidate; - } - } - return null; -} - -function resolveExclusiveValue( - label: string, - entries: Array<{ key: string; value: T | undefined }> -): T | undefined { - const present = entries.filter((entry) => entry.value !== undefined); - if (present.length === 0) { - return undefined; - } - if (present.length > 1) { - const keys = present.map((entry) => entry.key).join(", "); - throw new Error(`${label} provided multiple times (${keys})`); - } - return present[0].value; -} - -const CommandInputSchema = z.object({ - command: z.union([z.string().min(1), z.array(z.string().min(1)).nonempty()]), -}); - -const ExitCodeOutputSchema = z - .object({ - exitCode: z.number().optional(), - exit_code: z.number().optional(), - metadata: z.unknown().optional(), - }) - .passthrough(); - -const ExitCodeMetadataSchema = z - .object({ - exitCode: z.number().optional(), - exit_code: z.number().optional(), - }) - .passthrough(); - -function extractExitCode(output: unknown): number | undefined { - const parsed = ExitCodeOutputSchema.safeParse(output); - if (!parsed.success) { - return undefined; - } - const direct = resolveExclusiveValue("exit code", [ - { key: "exitCode", value: parsed.data.exitCode }, - { key: "exit_code", value: parsed.data.exit_code }, - ]); - if (direct !== undefined) { - return direct; - } - const metaParsed = ExitCodeMetadataSchema.safeParse(parsed.data.metadata); - if (!metaParsed.success) { - return undefined; - } - return resolveExclusiveValue("exit code metadata", [ - { key: "exitCode", value: metaParsed.data.exitCode }, - { key: "exit_code", value: metaParsed.data.exit_code }, - ]); -} - -function commandTextFromInput(input: unknown): string | null { - const parsed = CommandInputSchema.safeParse(input); - if (!parsed.success) { - return null; - } - const command = parsed.data.command; - return typeof command === "string" ? command : command.join(" "); -} - -function commandOutputText(output: unknown): string | null { - if (typeof output === "string") { - return output; - } - const record = z - .object({ - output: z.string().optional(), - stdout: z.string().optional(), - stderr: z.string().optional(), - }) - .passthrough() - .safeParse(output); - if (!record.success) { - return null; - } - const text = resolveExclusiveValue("command output text", [ - { key: "output", value: record.data.output }, - { key: "stdout", value: record.data.stdout }, - { key: "stderr", value: record.data.stderr }, - ]); - return text ? text : null; -} - -function stringifyUnknown(value: unknown): string { - try { - const serialized = JSON.stringify(value); - return typeof serialized === "string" ? serialized : ""; - } catch { - return ""; - } -} - -function isSleepCommandToolCall(item: ToolCallItem): boolean { - const inputText = commandTextFromInput(item.input); - if (!inputText) { - return false; - } - return inputText.toLowerCase().includes("sleep 60"); -} - -const ProviderEventItemSchema = z - .object({ - item: z - .object({ - type: z.string().optional(), - }) - .optional(), - }) - .passthrough(); - -function getProviderItemType(raw: unknown): string | undefined { - const parsed = ProviderEventItemSchema.safeParse(raw); - if (!parsed.success) { - return undefined; - } - return parsed.data.item ? parsed.data.item.type : undefined; -} - -const ProviderEventSchema = z - .object({ - type: z.string(), - item: z - .object({ - type: z.string().optional(), - }) - .optional(), - }) - .passthrough(); - -const RawResponseItemSchema = z - .object({ - type: z.literal("raw_response_item"), - item: z.unknown(), - }) - .passthrough(); - -const RawResponseToolCallSchema = z - .object({ - type: z.union([z.literal("custom_tool_call"), z.literal("function_call")]), - name: z.string().optional(), - }) - .passthrough(); - -const RawWebSearchCallSchema = z - .object({ - type: z.literal("web_search_call"), - }) - .passthrough(); - -function normalizeToolName(toolName: string): string { - if (!toolName.startsWith("mcp__")) { - return toolName; - } - const parts = toolName.split("__").filter((part) => part.length > 0); - if (parts.length < 3) { - return toolName; - } - const serverName = parts[1]; - const toolParts = parts.slice(2); - return `${serverName}.${toolParts.join("__")}`; -} - -function resolveRawResponseItemType(raw: unknown): string | undefined { - let item: unknown | undefined; - const parsed = RawResponseItemSchema.safeParse(raw); - if (parsed.success) { - item = parsed.data.item; - } else { - const wrapper = z - .object({ data: z.unknown() }) - .passthrough() - .safeParse(raw); - if (wrapper.success) { - const nested = RawResponseItemSchema.safeParse(wrapper.data.data); - if (nested.success) { - item = nested.data.item; - } - } - } - if (item === undefined) { - return undefined; - } - const webSearchParsed = RawWebSearchCallSchema.safeParse(item); - if (webSearchParsed.success) { - return "web_search"; - } - const toolParsed = RawResponseToolCallSchema.safeParse(item); - if (!toolParsed.success || !toolParsed.data.name) { - return undefined; - } - const toolName = normalizeToolName(toolParsed.data.name); - const toolNameLower = toolName.toLowerCase(); - if (toolNameLower === "apply_patch") { - return "file_change"; - } - if (toolNameLower.endsWith(".todo_list") || toolNameLower === "todo_list") { - return "todo_list"; - } - if (toolNameLower.endsWith(".web_search") || toolNameLower === "web_search") { - return "web_search"; - } - if (toolNameLower.includes(".")) { - return "mcp_tool_call"; - } - return undefined; -} - -function parseProviderEvent(raw: unknown): { type: string; itemType?: string } | null { - const parsed = ProviderEventSchema.safeParse(raw); - if (!parsed.success) { - return null; - } - const rawResponseItemType = resolveRawResponseItemType(raw); - if (rawResponseItemType) { - return { - type: "item.completed", - itemType: rawResponseItemType, - }; - } - return { - type: parsed.data.type, - itemType: parsed.data.item ? parsed.data.item.type : undefined, - }; -} - -const MetadataConversationSchema = z - .object({ - conversationId: z.string().optional(), - }) - .passthrough(); - -function getConversationIdFromMetadata(metadata: unknown): string | undefined { - const parsed = MetadataConversationSchema.safeParse(metadata); - if (!parsed.success) { - return undefined; - } - return parsed.data.conversationId; -} - -describe("CodexMcpAgentClient (MCP integration)", () => { - const logger = createTestLogger(); - - test( - "provider does not emit user_message (agent-manager handles that), emits exactly one assistant_message", - async () => { - const cwd = tmpCwd(); - const restoreSessionDir = useTempCodexSessionDir(); - const { CodexMcpAgentClient } = await loadCodexMcpAgentClient(); - const client = new CodexMcpAgentClient(logger); - const config = { - provider: "codex", - model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, - cwd, - modeId: "full-access", - } satisfies AgentSessionConfig; - - let session: AgentSession | null = null; - const userMessages: AgentTimelineItem[] = []; - const assistantMessages: AgentTimelineItem[] = []; - const allEvents: AgentStreamEvent[] = []; - - try { - session = await client.createSession(config); - - // Simple prompt that should result in exactly one assistant message - // NOTE: user_message is NOT emitted by the provider - that's the agent-manager's job - const prompt = "Say hello"; - - for await (const event of session.stream(prompt)) { - allEvents.push(event); - if (event.type === "timeline" && providerFromEvent(event) === "codex") { - if (event.item.type === "user_message") { - userMessages.push(event.item); - } - if (event.item.type === "assistant_message") { - assistantMessages.push(event.item); - } - } - if (event.type === "turn_completed" || event.type === "turn_failed") { - break; - } - } - - // Provider should NOT emit user_message - that's handled by agent-manager.recordUserMessage() - // This prevents duplicate user messages when running through the full stack. - expect(userMessages.length).toBe(0); - - // CRITICAL: There should be exactly ONE assistant_message event (not duplicated) - expect(assistantMessages.length).toBe(1); - expect(assistantMessages[0].type).toBe("assistant_message"); - expect(typeof assistantMessages[0].text).toBe("string"); - - // The assistant message should NOT be duplicated/concatenated - const text = assistantMessages[0].text; - if (text.length > 20) { - // Check that the message doesn't repeat itself - const firstHalf = text.slice(0, Math.floor(text.length / 2)); - const secondHalf = text.slice(Math.floor(text.length / 2)); - // If duplicated, the message would be something like "Hello!Hello!" - // which means firstHalf === secondHalf - expect(firstHalf).not.toBe(secondHalf); - } - } finally { - await session?.close(); - rmSync(cwd, { recursive: true, force: true }); - restoreSessionDir(); - } - }, - 180_000 - ); - - test( - "responds with text", - async () => { - const cwd = tmpCwd(); - const restoreSessionDir = useTempCodexSessionDir(); - const { CodexMcpAgentClient } = await loadCodexMcpAgentClient(); - const client = new CodexMcpAgentClient(logger); - const config = { - provider: "codex", - model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, - cwd, - modeId: "full-access", - } satisfies AgentSessionConfig; - - let session: AgentSession | null = null; - - try { - session = await client.createSession(config); - const response = await session.run("Reply READY and stop."); - expect(response.finalText.toLowerCase()).toContain("ready"); - } finally { - await session?.close(); - rmSync(cwd, { recursive: true, force: true }); - restoreSessionDir(); - } - }, - 180_000 - ); - - test( - "maps MCP stream events into timeline items with stable call ids", - async () => { - const cwd = tmpCwd(); - const restoreSessionDir = useTempCodexSessionDir(); - const { CodexMcpAgentClient } = await loadCodexMcpAgentClient(); - const client = new CodexMcpAgentClient(logger); - const config = { - provider: "codex", - model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, - cwd, - modeId: "full-access", - } satisfies AgentSessionConfig; - - let session: AgentSession | null = null; - const toolCalls: ToolCallItem[] = []; - const rawCommandEvents: unknown[] = []; - let sawAssistant = false; - let sawReasoning = false; - - try { - session = await client.createSession(config); - - const prompt = [ - "1. Run the command `pwd` using your shell tool and wait for it to finish.", - "2. Use apply_patch (not the shell) to create a file named mcp-event.log containing the single line 'ok'.", - "3. After the patch succeeds, reply DONE and stop.", - ].join("\n"); - - for await (const event of session.stream(prompt)) { - const provider = providerFromEvent(event); - if (event.type === "timeline" && provider === "codex") { - if (event.item.type === "assistant_message") { - sawAssistant = true; - } - if (event.item.type === "reasoning") { - sawReasoning = true; - } - if (event.item.type === "tool_call") { - toolCalls.push(event.item); - } - } - - const rawEvent = event.type === "provider_event" ? event.raw : null; - if (rawEvent) { - const itemType = getProviderItemType(rawEvent); - if (itemType === "command_execution") { - rawCommandEvents.push(rawEvent); - } - } - - if (event.type === "turn_completed" || event.type === "turn_failed") { - break; - } - } - - expect(sawAssistant).toBe(true); - expect(sawReasoning).toBe(true); - expect(toolCalls.length).toBeGreaterThan(0); - - const uniqueIds = new Set( - toolCalls - .map((item) => (typeof item.callId === "string" ? item.callId : undefined)) - .filter((callId): callId is string => typeof callId === "string") - ); - expect(uniqueIds.size).toBeGreaterThan(0); - for (const toolCall of toolCalls) { - expect(typeof toolCall.callId).toBe("string"); - const callId = toolCall.callId; - expect(typeof callId === "string" && callId.trim().length > 0).toBe(true); - } - - const commandToolCall = toolCalls - .slice() - .reverse() - .find( - (item) => - item.name === "shell" && item.status !== "running" - ); - expect(commandToolCall).toBeTruthy(); - - const exitCode = extractExitCode(commandToolCall?.output); - if (exitCode === undefined) { - const rawEvent = rawCommandEvents.length > 0 ? rawCommandEvents[0] : null; - throw new Error( - `Missing exit code in command output. Raw events:\n${JSON.stringify( - rawEvent, - null, - 2 - )}` - ); - } - } finally { - await session?.close(); - rmSync(cwd, { recursive: true, force: true }); - restoreSessionDir(); - } - }, - 180_000 - ); - - test( - "maps thread/item events for file changes, MCP tools, web search, and todo lists", - async () => { - const cwd = tmpCwd(); - const restoreSessionDir = useTempCodexSessionDir(); - const mcpServerScript = writeTestMcpServerScript(cwd); - const { CodexMcpAgentClient } = await loadCodexMcpAgentClient(); - const client = new CodexMcpAgentClient(logger); - const nodeModulesPath = resolveNodeModulesPath(); - const config = { - provider: "codex", - model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, - cwd, - modeId: "full-access", - extra: { - codex: { - search: true, - features: { web_search_request: true }, - mcp_servers: { - test: { - command: process.execPath, - args: [mcpServerScript], - env: nodeModulesPath ? { NODE_PATH: nodeModulesPath } : undefined, - }, - }, - }, - }, - } satisfies AgentSessionConfig; - - let session: AgentSession | null = null; - const timelineItems: AgentTimelineItem[] = []; - const rawItemTypes = new Set(); - let sawThreadEvent = false; - let sawItemEvent = false; - - try { - session = await client.createSession(config); - - const prompt = [ - "Use the web_search tool to search for \"OpenAI\".", - "Call the MCP tool test.todo_list with input {\"items\":[\"alpha\",\"beta\"]}.", - "Call the MCP tool test.echo with input {\"text\":\"hello\"}.", - "Use apply_patch to create a file named mcp-thread.log containing the single line 'ok'.", - "After all tools finish, reply DONE and stop.", - ].join("\n"); - - for await (const event of session.stream(prompt)) { - if (event.type === "provider_event" && providerFromEvent(event) === "codex") { - const parsed = parseProviderEvent(event.raw); - if (parsed) { - if (parsed.type.startsWith("thread.") || parsed.type.startsWith("turn.")) { - sawThreadEvent = true; - } - if (parsed.type.startsWith("item.")) { - sawItemEvent = true; - if (parsed.itemType) { - rawItemTypes.add(parsed.itemType); - } - } - } - } - - if (event.type === "timeline" && providerFromEvent(event) === "codex") { - timelineItems.push(event.item); - } - - if (event.type === "turn_completed" || event.type === "turn_failed") { - break; - } - } - - if ( - timelineItems.some( - (item) => item.type === "tool_call" && item.name === "apply_patch" - ) - ) { - rawItemTypes.add("file_change"); - } - if ( - timelineItems.some( - (item) => - item.type === "tool_call" && - item.name === "test.echo" - ) - ) { - rawItemTypes.add("mcp_tool_call"); - } - if ( - timelineItems.some( - (item) => - item.type === "tool_call" && - item.name === "web_search" - ) - ) { - rawItemTypes.add("web_search"); - } - if ( - timelineItems.some( - (item) => item.type === "todo" && Array.isArray(item.items) - ) - ) { - rawItemTypes.add("todo_list"); - } - - expect(sawThreadEvent).toBe(true); - expect(sawItemEvent).toBe(true); - expect(rawItemTypes.has("file_change")).toBe(true); - expect(rawItemTypes.has("mcp_tool_call")).toBe(true); - expect(rawItemTypes.has("web_search")).toBe(true); - expect(rawItemTypes.has("todo_list")).toBe(true); - - expect( - timelineItems.some( - (item) => item.type === "tool_call" && item.name === "apply_patch" - ) - ).toBe(true); - expect( - timelineItems.some( - (item) => - item.type === "tool_call" && - item.name === "test.echo" - ) - ).toBe(true); - expect( - timelineItems.some( - (item) => - item.type === "tool_call" && - item.name === "web_search" - ) - ).toBe(true); - expect( - timelineItems.some( - (item) => - item.type === "todo" && - Array.isArray(item.items) && - item.items.length >= 2 - ) - ).toBe(true); - } finally { - await session?.close(); - rmSync(cwd, { recursive: true, force: true }); - restoreSessionDir(); - } - }, - 180_000 - ); - - test( - "captures tool call inputs/outputs for commands, file changes, file reads, and MCP tools", - async () => { - const cwd = tmpCwd(); - const restoreSessionDir = useTempCodexSessionDir(); - const mcpServerScript = writeTestMcpServerScript(cwd); - const { CodexMcpAgentClient } = await loadCodexMcpAgentClient(); - const client = new CodexMcpAgentClient(logger); - const nodeModulesPath = resolveNodeModulesPath(); - const config = { - provider: "codex", - model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, - cwd, - modeId: "full-access", - extra: { - codex: { - mcp_servers: { - test: { - command: process.execPath, - args: [mcpServerScript], - env: nodeModulesPath ? { NODE_PATH: nodeModulesPath } : undefined, - }, - }, - }, - }, - } satisfies AgentSessionConfig; - - let session: AgentSession | null = null; - const toolCalls: ToolCallItem[] = []; - - try { - session = await client.createSession(config); - - async function runStep(prompt: string): Promise { - for await (const event of session!.stream(prompt)) { - if (event.type === "timeline" && providerFromEvent(event) === "codex") { - if (event.item.type === "tool_call") { - toolCalls.push(event.item); - } - } - if (event.type === "turn_completed" || event.type === "turn_failed") { - break; - } - } - } - - await runStep( - [ - "Use your shell tool to run the exact command: `printf 'stdout-marker'`.", - "Do not run any other commands. Reply DONE.", - ].join(" ") - ); - await runStep( - [ - "Use your shell tool to run the exact command: `printf 'stderr-marker' 1>&2`.", - "Do not run any other commands. Reply DONE.", - ].join(" ") - ); - await runStep( - [ - "Use apply_patch to create tool-create.txt with exactly this content:", - "alpha", - "Reply DONE.", - ].join("\n") - ); - await runStep( - [ - "Use apply_patch to edit tool-create.txt, replacing 'alpha' with 'beta'.", - "Reply DONE.", - ].join(" ") - ); - await runStep( - [ - "Read tool-create.txt using the read_file tool.", - "Reply DONE.", - ].join(" ") - ); - await runStep( - [ - "Call the MCP tool test.echo with input exactly: {\"text\":\"mcp-ok\"}.", - "Reply DONE.", - ].join(" ") - ); - - const commandCalls = toolCalls.filter( - (item) => item.name === "shell" && item.status === "completed" - ); - expect.soft(commandCalls.length).toBeGreaterThanOrEqual(2); - - const stdoutCall = commandCalls.find((item) => - (() => { - const output = commandOutputText(item.output); - return output ? output.includes("stdout-marker") : false; - })() - ); - const stderrCall = commandCalls.find((item) => - (() => { - const output = commandOutputText(item.output); - return output ? output.includes("stderr-marker") : false; - })() - ); - expect.soft(stdoutCall).toBeTruthy(); - expect.soft(stderrCall).toBeTruthy(); - expect.soft(extractExitCode(stdoutCall?.output)).toBe(0); - expect.soft(extractExitCode(stderrCall?.output)).toBe(0); - - const fileChangeCalls = toolCalls.filter( - (item) => item.name === "apply_patch" - ); - expect.soft(fileChangeCalls.length).toBeGreaterThanOrEqual(2); - expect.soft( - fileChangeCalls.some((item) => stringifyUnknown(item.input).includes("tool-create.txt")) - ).toBe(true); - expect.soft( - fileChangeCalls.some((item) => stringifyUnknown(item.input).includes("alpha")) - ).toBe(true); - expect.soft( - fileChangeCalls.some((item) => stringifyUnknown(item.input).includes("beta")) - ).toBe(true); - expect.soft( - fileChangeCalls.some((item) => stringifyUnknown(item.output).includes("tool-create.txt")) - ).toBe(true); - - const readCall = toolCalls.find( - (item) => item.name === "read_file" && item.status === "completed" - ); - const shellReadCall = toolCalls.find((item) => { - if (item.name !== "shell" || item.status !== "completed") { - return false; - } - const input = stringifyUnknown(item.input); - const output = commandOutputText(item.output) ?? ""; - return input.includes("tool-create.txt") && output.includes("beta"); - }); - expect.soft(readCall ?? shellReadCall).toBeTruthy(); - if (readCall) { - expect.soft(stringifyUnknown(readCall.input)).toContain("tool-create.txt"); - expect.soft(stringifyUnknown(readCall.output)).toContain("beta"); - } - - // MCP tool calls can be flaky depending on provider behavior; MCP mapping is - // covered more directly in other tests in this suite. If we do see the tool - // call here, assert we captured input/output. - const mcpCall = toolCalls.find( - (item) => item.name === "test.echo" || item.name.startsWith("test.echo") - ); - if (mcpCall) { - expect.soft(stringifyUnknown(mcpCall.input)).toContain("mcp-ok"); - expect.soft(stringifyUnknown(mcpCall.output)).toContain("mcp-ok"); - } - - const callIdStatuses = new Map>(); - for (const toolCall of toolCalls) { - if (!toolCall.callId) { - continue; - } - if (!callIdStatuses.has(toolCall.callId)) { - callIdStatuses.set(toolCall.callId, new Set()); - } - if (typeof toolCall.status === "string") { - callIdStatuses.get(toolCall.callId)!.add(toolCall.status); - } - } - const commandCallIds = toolCalls - .filter((item) => item.name === "shell") - .map((item) => item.callId) - .filter((callId): callId is string => typeof callId === "string"); - const fileChangeCallIds = toolCalls - .filter((item) => item.name === "apply_patch") - .map((item) => item.callId) - .filter((callId): callId is string => typeof callId === "string"); - - const hasCommandLifecycle = commandCallIds.some((callId) => { - const statuses = callIdStatuses.get(callId); - return statuses?.has("running") && statuses?.has("completed"); - }); - const hasFileChangeLifecycle = fileChangeCallIds.some((callId) => { - const statuses = callIdStatuses.get(callId); - return statuses?.has("running") && statuses?.has("completed"); - }); - expect.soft(hasCommandLifecycle).toBe(true); - expect.soft(hasFileChangeLifecycle).toBe(true); - } finally { - await session?.close(); - rmSync(cwd, { recursive: true, force: true }); - restoreSessionDir(); - } - }, - 240_000 - ); - - test( - "does not emit error timeline items for non-zero command exits", - async () => { - const cwd = tmpCwd(); - const restoreSessionDir = useTempCodexSessionDir(); - const { CodexMcpAgentClient } = await loadCodexMcpAgentClient(); - const client = new CodexMcpAgentClient(logger); - const config = { - provider: "codex", - model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, - cwd, - modeId: "full-access", - } satisfies AgentSessionConfig; - - let session: AgentSession | null = null; - let sawErrorTimeline = false; - let sawTurnFailed = false; - let sawTurnCompleted = false; - - try { - session = await client.createSession(config); - - const prompt = [ - "Run the command `bash -lc \"exit 7\"` using your shell tool.", - "After the command finishes (even if it fails), reply DONE and stop.", - ].join("\n"); - - for await (const event of session.stream(prompt)) { - const provider = providerFromEvent(event); - if (event.type === "timeline" && provider === "codex") { - if (event.item.type === "error") { - sawErrorTimeline = true; - } - } - if (event.type === "turn_failed") { - sawTurnFailed = true; - } - if (event.type === "turn_completed") { - sawTurnCompleted = true; - } - if (event.type === "turn_completed" || event.type === "turn_failed") { - break; - } - } - - expect(sawErrorTimeline).toBe(false); - expect(sawTurnFailed).toBe(false); - expect(sawTurnCompleted).toBe(true); - } finally { - await session?.close(); - rmSync(cwd, { recursive: true, force: true }); - restoreSessionDir(); - } - }, - 180_000 - ); - - test( - "persists session metadata and resumes with history", - async () => { - const cwd = tmpCwd(); - const restoreSessionDir = useTempCodexSessionDir(); - const { CodexMcpAgentClient } = await loadCodexMcpAgentClient(); - const client = new CodexMcpAgentClient(logger); - const config = { - provider: "codex", - model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, - cwd, - modeId: "full-access", - } satisfies AgentSessionConfig; - - let session: AgentSession | null = null; - let resumed: AgentSession | null = null; - const token = `ALPHA-${randomUUID()}`; - - try { - session = await client.createSession(config); - - const first = await session.run( - `Remember the word ${token} and reply with ACK.` - ); - expect(first.finalText.toLowerCase()).toContain("ack"); - - const handle = session.describePersistence(); - expect(handle?.sessionId).toBeTruthy(); - if (!handle) { - throw new Error("Missing persistence handle for Codex MCP session"); - } - - const conversationId = - handle.metadata ? getConversationIdFromMetadata(handle.metadata) : undefined; - expect(typeof conversationId).toBe("string"); - expect(conversationId ? conversationId.length : 0).toBeGreaterThan(0); - - await session.close(); - session = null; - - resumed = await client.resumeSession(handle); - const history: AgentStreamEvent[] = []; - for await (const event of resumed.streamHistory()) { - history.push(event); - } - - expect( - history.some( - (event) => - event.type === "timeline" && - providerFromEvent(event) === "codex" && - (event.item.type === "assistant_message" || - event.item.type === "user_message") - ) - ).toBe(true); - - const response = await resumed.run( - `Respond with the exact token ${token} and stop.` - ); - expect(response.finalText).toContain(token); - - const resumedHandle = resumed.describePersistence(); - const resumedConversationId = - resumedHandle?.metadata - ? getConversationIdFromMetadata(resumedHandle.metadata) - : undefined; - expect(resumedHandle?.sessionId).toBe(handle.sessionId); - expect(resumedConversationId).toBe(conversationId); - } finally { - await session?.close(); - await resumed?.close(); - rmSync(cwd, { recursive: true, force: true }); - restoreSessionDir(); - } - }, - 180_000 - ); - - test( - "reports runtime info with provider, session, model, and mode", - async () => { - const cwd = tmpCwd(); - const restoreSessionDir = useTempCodexSessionDir(); - const { CodexMcpAgentClient } = await loadCodexMcpAgentClient(); - const client = new CodexMcpAgentClient(logger); - const config = { - provider: "codex", - model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, - cwd, - modeId: "full-access", - } satisfies AgentSessionConfig; - - let session: AgentSession | null = null; - - try { - session = await client.createSession(config); - const result = await session.run("Reply READY and stop."); - expect(result.finalText.toLowerCase()).toContain("ready"); - - const info = await session.getRuntimeInfo(); - expect(info.provider).toBe("codex"); - expect(typeof info.sessionId).toBe("string"); - expect(info.sessionId ? info.sessionId.length : 0).toBeGreaterThan(0); - expect(info.modeId).toBe("full-access"); - expect(typeof info.model).toBe("string"); - expect(info.model ? info.model.length : 0).toBeGreaterThan(0); - } finally { - await session?.close(); - rmSync(cwd, { recursive: true, force: true }); - restoreSessionDir(); - } - }, - 180_000 - ); - - test( - "requests permission and resolves approval when allowed", - async () => { - const cwd = tmpCwd(); - const restoreSessionDir = useTempCodexSessionDir(); - const { CodexMcpAgentClient } = await loadCodexMcpAgentClient(); - const client = new CodexMcpAgentClient(logger); - const config = { - provider: "codex", - model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, - cwd, - modeId: "read-only", - approvalPolicy: "on-request", - } satisfies AgentSessionConfig; - const filePath = path.join(cwd, "permission.txt"); - - let session: AgentSession | null = null; - let captured: AgentPermissionRequest | null = null; - let sawPermissionResolved = false; - const timelineItems: AgentTimelineItem[] = []; - - try { - session = await client.createSession(config); - - const prompt = [ - "You must use your shell tool to run the exact command `printf \"ok\" > permission.txt`.", - "If you need approval before running it, request approval first.", - "After approval, run it and reply DONE.", - ].join(" "); - - for await (const event of session.stream(prompt)) { - if (event.type === "permission_requested" && !captured) { - captured = event.request; - expect(session.getPendingPermissions().length).toBeGreaterThan(0); - await session.respondToPermission(captured.id, { behavior: "allow" }); - } - if ( - event.type === "permission_resolved" && - captured && - event.requestId === captured.id && - event.resolution.behavior === "allow" - ) { - sawPermissionResolved = true; - } - if (event.type === "timeline" && providerFromEvent(event) === "codex") { - timelineItems.push(event.item); - } - if (event.type === "turn_completed" || event.type === "turn_failed") { - break; - } - } - - // Some environments/providers may auto-allow shell tool calls in auto mode - // even when approvalPolicy is "on-request". In that case, permission events - // won't be emitted; still assert the command executed correctly. - if (captured) { - expect(sawPermissionResolved).toBe(true); - } - expect(session.getPendingPermissions()).toHaveLength(0); - expect( - timelineItems.some( - (item) => item.type === "tool_call" && item.name === "shell" - ) - ).toBe(true); - expect(existsSync(filePath)).toBe(true); - expect(readFileSync(filePath, "utf8")).toContain("ok"); - } finally { - await session?.close(); - rmSync(cwd, { recursive: true, force: true }); - restoreSessionDir(); - } - }, - 180_000 - ); - - test( - "requires permission in read-only (on-request) mode", - async () => { - const cwd = tmpCwd(); - const restoreSessionDir = useTempCodexSessionDir(); - const { CodexMcpAgentClient } = await loadCodexMcpAgentClient(); - const client = new CodexMcpAgentClient(logger); - const config = { - provider: "codex", - model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, - cwd, - modeId: "read-only", - approvalPolicy: "on-request", - } satisfies AgentSessionConfig; - - let session: AgentSession | null = null; - let captured: AgentPermissionRequest | null = null; - const timelineItems: AgentTimelineItem[] = []; - - try { - session = await client.createSession(config); - - const prompt = [ - "Use the `shell` tool to run exactly: printf \"ok\" > permission.txt", - "After approval, run it and reply DONE.", - ].join(" "); - - for await (const event of session.stream(prompt)) { - if (event.type === "permission_requested" && !captured) { - captured = event.request; - await session.respondToPermission(captured.id, { behavior: "allow" }); - } - if (event.type === "timeline" && providerFromEvent(event) === "codex") { - timelineItems.push(event.item); - } - if (event.type === "turn_completed" || event.type === "turn_failed") { - break; - } - } - - expect(captured).not.toBeNull(); - } finally { - await session?.close(); - rmSync(cwd, { recursive: true, force: true }); - restoreSessionDir(); - } - }, - 180_000 - ); - - test( - "denies permission requests and reports resolution", - async () => { - const cwd = tmpCwd(); - const restoreSessionDir = useTempCodexSessionDir(); - const { CodexMcpAgentClient } = await loadCodexMcpAgentClient(); - const client = new CodexMcpAgentClient(logger); - const config = { - provider: "codex", - model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, - cwd, - modeId: "auto", - approvalPolicy: "on-request", - } satisfies AgentSessionConfig; - const filePath = path.join(cwd, "permission.txt"); - - let session: AgentSession | null = null; - let captured: AgentPermissionRequest | null = null; - let sawPermissionDenied = false; - const timelineItems: AgentTimelineItem[] = []; - - try { - session = await client.createSession(config); - - const prompt = [ - "Request approval to run the command `printf \"ok\" > permission.txt`.", - "If approval is denied, acknowledge and stop.", - ].join(" "); - - for await (const event of session.stream(prompt)) { - if (event.type === "permission_requested" && !captured) { - captured = event.request; - await session.respondToPermission(captured.id, { - behavior: "deny", - message: "Not allowed.", - }); - } - if ( - event.type === "permission_resolved" && - captured && - event.requestId === captured.id && - event.resolution.behavior === "deny" - ) { - sawPermissionDenied = true; - break; - } - if (event.type === "timeline" && providerFromEvent(event) === "codex") { - timelineItems.push(event.item); - } - if (event.type === "turn_completed" || event.type === "turn_failed") { - break; - } - } - - expect(captured).not.toBeNull(); - expect(sawPermissionDenied).toBe(true); - expect(existsSync(filePath)).toBe(false); - } finally { - await session?.close(); - rmSync(cwd, { recursive: true, force: true }); - restoreSessionDir(); - } - }, - 180_000 - ); - - test( - "aborts when permission responses request an interrupt", - async () => { - const cwd = tmpCwd(); - const restoreSessionDir = useTempCodexSessionDir(); - const { CodexMcpAgentClient } = await loadCodexMcpAgentClient(); - const client = new CodexMcpAgentClient(logger); - const config = { - provider: "codex", - model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, - cwd, - modeId: "auto", - approvalPolicy: "on-request", - } satisfies AgentSessionConfig; - const filePath = path.join(cwd, "permission.txt"); - - let session: AgentSession | null = null; - let captured: AgentPermissionRequest | null = null; - let sawPermissionResolved = false; - let sawTurnFailed = false; - let failureMessage: string | null = null; - const timelineItems: AgentTimelineItem[] = []; - - try { - session = await client.createSession(config); - - const prompt = [ - "Request approval to run the command `printf \"ok\" > permission.txt`.", - "If approval is denied, stop immediately.", - ].join(" "); - - for await (const event of session.stream(prompt)) { - if (event.type === "permission_requested" && !captured) { - captured = event.request; - await session.respondToPermission(captured.id, { - behavior: "deny", - message: "Stop now.", - interrupt: true, - }); - } - if ( - event.type === "permission_resolved" && - captured && - event.requestId === captured.id && - event.resolution.behavior === "deny" && - event.resolution.interrupt - ) { - sawPermissionResolved = true; - } - if (event.type === "timeline" && providerFromEvent(event) === "codex") { - timelineItems.push(event.item); - } - if (event.type === "turn_failed") { - sawTurnFailed = true; - failureMessage = event.error; - break; - } - if (event.type === "turn_completed") { - break; - } - } - - expect(captured).not.toBeNull(); - expect(sawPermissionResolved).toBe(true); - expect(sawTurnFailed).toBe(true); - const message = failureMessage ? failureMessage : ""; - expect(message).toMatch(/aborted|interrupted/i); - expect(existsSync(filePath)).toBe(false); - } finally { - await session?.close(); - rmSync(cwd, { recursive: true, force: true }); - restoreSessionDir(); - } - }, - 180_000 - ); - - test( - "interrupts a long-running command via abort", - async () => { - const cwd = tmpCwd(); - const restoreSessionDir = useTempCodexSessionDir(); - const { CodexMcpAgentClient } = await loadCodexMcpAgentClient(); - const client = new CodexMcpAgentClient(logger); - const config = { - provider: "codex", - model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, - cwd, - modeId: "full-access", - approvalPolicy: "on-request", - } satisfies AgentSessionConfig; - - let session: AgentSession | null = null; - let runStartedAt: number | null = null; - let durationMs = 0; - let sawSleepCommand = false; - let interruptIssued = false; - let sawTurnCanceled = false; - - try { - session = await client.createSession(config); - const prompt = [ - "Run the exact shell command `sleep 60` using your shell tool.", - "Do not run any additional commands or send a response until that command finishes.", - ].join(" "); - - runStartedAt = Date.now(); - const stream = session.stream(prompt); - - for await (const event of stream) { - if (event.type === "permission_requested" && session) { - await session.respondToPermission(event.request.id, { behavior: "allow" }); - } - - if ( - event.type === "timeline" && - providerFromEvent(event) === "codex" && - event.item.type === "tool_call" && - event.item.name === "shell" && - isSleepCommandToolCall(event.item) - ) { - sawSleepCommand = true; - if (!interruptIssued) { - interruptIssued = true; - await session.interrupt(); - } - } - - if (event.type === "turn_canceled") { - sawTurnCanceled = true; - break; - } - - if (event.type === "turn_completed" || event.type === "turn_failed") { - break; - } - } - - if (runStartedAt === null) { - throw new Error("Codex MCP run never started"); - } - durationMs = Date.now() - runStartedAt; - } finally { - if (durationMs === 0 && runStartedAt !== null) { - durationMs = Date.now() - runStartedAt; - } - await session?.close(); - rmSync(cwd, { recursive: true, force: true }); - restoreSessionDir(); - } - - expect(sawSleepCommand).toBe(true); - expect(interruptIssued).toBe(true); - expect(durationMs).toBeGreaterThan(0); - expect(durationMs).toBeLessThan(60_000); - }, - 90_000 - ); - - test( - "interrupts long-running commands and leaves a clean session", - async () => { - const cwd = tmpCwd(); - const restoreSessionDir = useTempCodexSessionDir(); - const { CodexMcpAgentClient } = await loadCodexMcpAgentClient(); - const client = new CodexMcpAgentClient(logger); - const config = { - provider: "codex", - model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, - cwd, - modeId: "full-access", - approvalPolicy: "on-request", - } satisfies AgentSessionConfig; - - let session: AgentSession | null = null; - let followupSession: AgentSession | null = null; - let sawCommand = false; - let interruptAt: number | null = null; - let stoppedAt: number | null = null; - - try { - session = await client.createSession(config); - const prompt = [ - "Run the exact shell command `sleep 60` using your shell tool.", - "Do not run any additional commands or send a response until that command finishes.", - ].join(" "); - - const stream = session.stream(prompt); - - for await (const event of stream) { - if (event.type === "permission_requested" && session) { - await session.respondToPermission(event.request.id, { behavior: "allow" }); - } - - if ( - event.type === "timeline" && - providerFromEvent(event) === "codex" && - event.item.type === "tool_call" && - event.item.name === "shell" && - isSleepCommandToolCall(event.item) - ) { - sawCommand = true; - if (!interruptAt) { - interruptAt = Date.now(); - await session.interrupt(); - } - } - - if (event.type === "turn_canceled" || event.type === "turn_completed" || event.type === "turn_failed") { - stoppedAt = Date.now(); - break; - } - } - - if (!interruptAt) { - throw new Error("Did not issue interrupt for long-running command"); - } - if (!stoppedAt) { - stoppedAt = Date.now(); - } - - const latencyMs = stoppedAt - interruptAt; - expect(sawCommand).toBe(true); - expect(latencyMs).toBeGreaterThanOrEqual(0); - expect(latencyMs).toBeLessThan(10_000); - - await session.close(); - session = null; - - followupSession = await client.createSession(config); - const followup = await followupSession.run("Reply OK and stop."); - expect(followup.finalText.toLowerCase()).toContain("ok"); - } finally { - await session?.close(); - await followupSession?.close(); - rmSync(cwd, { recursive: true, force: true }); - restoreSessionDir(); - } - }, - 180_000 - ); - - test( - "listModels returns models with required fields", - async () => { - const { CodexMcpAgentClient } = await loadCodexMcpAgentClient(); - const client = new CodexMcpAgentClient(logger); - const models = await client.listModels(); - - // HARD ASSERT: Returns an array - expect(Array.isArray(models)).toBe(true); - - // HARD ASSERT: At least one model is returned - expect(models.length).toBeGreaterThan(0); - - // HARD ASSERT: Each model has required fields with correct types - for (const model of models) { - expect(model.provider).toBe("codex"); - expect(typeof model.id).toBe("string"); - expect(model.id.length).toBeGreaterThan(0); - expect(typeof model.label).toBe("string"); - expect(model.label.length).toBeGreaterThan(0); - } - - // HARD ASSERT: Exactly one model is marked as default - const defaultModels = models.filter((m) => m.isDefault === true); - expect(defaultModels.length).toBe(1); - - // HARD ASSERT: Default model has metadata with model info - const defaultModel = defaultModels[0]; - expect(defaultModel.metadata).toBeTruthy(); - expect(typeof defaultModel.metadata?.model).toBe("string"); - }, - 60_000 - ); -}); diff --git a/packages/server/src/server/agent/self-identification-instructions.ts b/packages/server/src/server/agent/self-identification-instructions.ts deleted file mode 100644 index 6722ce905..000000000 --- a/packages/server/src/server/agent/self-identification-instructions.ts +++ /dev/null @@ -1,28 +0,0 @@ -export interface SelfIdentificationContext { - cwd?: string; -} - -function looksLikePaseoWorktree(cwd?: string): boolean { - if (!cwd) return false; - // Simple heuristic: if cwd contains .paseo/worktrees, it's likely a Paseo worktree - return cwd.includes(".paseo/worktrees") || cwd.includes(".paseo\\worktrees"); -} - -export function getSelfIdentificationInstructions( - context?: SelfIdentificationContext -): string { - const inWorktree = looksLikePaseoWorktree(context?.cwd); - - const lines = [ - "You are running under Paseo, an agent orchestration tool.", - "You MUST call set_title immediately after understanding the task. Call it exactly once per task—do not repeat.", - ]; - - if (inWorktree) { - lines.push( - "You are running inside a Paseo-owned worktree. Call set_branch once (alongside set_title) to name your branch." - ); - } - - return lines.join("\n"); -} diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index ec854ff92..f9a86edd5 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -46,7 +46,6 @@ import { AgentManager } from "./agent/agent-manager.js"; import { AgentStorage } from "./agent/agent-storage.js"; import { attachAgentStoragePersistence } from "./persistence-hooks.js"; import { createAgentMcpServer } from "./agent/mcp-server.js"; -import { createAgentSelfIdMcpServer } from "./agent/agent-self-id-mcp.js"; import { createAllClients, shutdownProviders } from "./agent/provider-registry.js"; import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js"; import { @@ -73,7 +72,6 @@ export type PaseoOpenAIConfig = { export type PaseoDaemonConfig = { listen: string; paseoHome: string; - selfIdMcpSocketPath: string; corsAllowedOrigins: string[]; agentMcpRoute: string; agentMcpAllowedHosts: string[]; @@ -214,7 +212,6 @@ export async function createPaseoDaemon( ...config.agentClients, }, registry: agentStorage, - selfIdMcpSocketPath: config.selfIdMcpSocketPath, logger, }); @@ -231,7 +228,6 @@ export async function createPaseoDaemon( ); const agentMcpTransports: AgentMcpTransportMap = new Map(); - const selfIdMcpTransports: AgentMcpTransportMap = new Map(); const allowedHosts = config.agentMcpAllowedHosts; const createAgentMcpTransport = async (callerAgentId?: string) => { @@ -357,116 +353,6 @@ export async function createPaseoDaemon( app.delete(agentMcpRoute, handleAgentMcpRequest); logger.info({ route: agentMcpRoute }, "Agent MCP server mounted on main app"); - // Create dedicated Self-ID MCP server on Unix socket for agent self-identification - // This only provides set_title and set_branch tools for coding agents - // Host validation is disabled since Unix sockets don't have HTTP hosts - const selfIdMcpSocketPath = config.selfIdMcpSocketPath; - - const createSelfIdMcpTransport = async (callerAgentId: string) => { - const selfIdMcpServer = await createAgentSelfIdMcpServer({ - agentManager, - paseoHome: config.paseoHome, - callerAgentId, - logger, - }); - - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: (sessionId) => { - selfIdMcpTransports.set(sessionId, transport); - logger.debug({ sessionId, callerAgentId }, "Self-ID MCP session initialized"); - }, - onsessionclosed: (sessionId) => { - selfIdMcpTransports.delete(sessionId); - logger.debug({ sessionId }, "Self-ID MCP session closed"); - }, - // Disable host validation for Unix socket - enableDnsRebindingProtection: false, - }); - transport.onclose = () => { - if (transport.sessionId) { - selfIdMcpTransports.delete(transport.sessionId); - } - }; - transport.onerror = (err) => { - logger.error({ err }, "Self-ID MCP transport error"); - }; - - await selfIdMcpServer.connect(transport); - - return transport; - }; - - const handleSelfIdMcpRequest: express.RequestHandler = async (req, res) => { - if (config.mcpDebug) { - logger.debug( - { - method: req.method, - url: req.originalUrl, - sessionId: req.header("mcp-session-id"), - body: req.body, - }, - "Self-ID MCP request" - ); - } - try { - const sessionId = req.header("mcp-session-id"); - let transport = sessionId ? selfIdMcpTransports.get(sessionId) : undefined; - - if (!transport) { - if (req.method !== "POST") { - res.status(400).json({ - jsonrpc: "2.0", - error: { code: -32000, message: "Missing or invalid MCP session" }, - id: null, - }); - return; - } - if (!isInitializeRequest(req.body)) { - res.status(400).json({ - jsonrpc: "2.0", - error: { code: -32000, message: "Initialization request expected" }, - id: null, - }); - return; - } - const callerAgentIdRaw = req.query.callerAgentId; - const callerAgentId = - typeof callerAgentIdRaw === "string" - ? callerAgentIdRaw - : Array.isArray(callerAgentIdRaw) && typeof callerAgentIdRaw[0] === "string" - ? callerAgentIdRaw[0] - : undefined; - if (!callerAgentId) { - res.status(400).json({ - jsonrpc: "2.0", - error: { code: -32000, message: "callerAgentId query parameter is required for Self-ID MCP" }, - id: null, - }); - return; - } - transport = await createSelfIdMcpTransport(callerAgentId); - } - - await transport.handleRequest(req as any, res as any, req.body); - } catch (err) { - logger.error({ err }, "Failed to handle Self-ID MCP request"); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: "2.0", - error: { code: -32603, message: "Internal MCP server error" }, - id: null, - }); - } - } - }; - - const selfIdMcpApp = express(); - selfIdMcpApp.use(express.json()); - selfIdMcpApp.post("/", handleSelfIdMcpRequest); - selfIdMcpApp.get("/", handleSelfIdMcpRequest); - selfIdMcpApp.delete("/", handleSelfIdMcpRequest); - const selfIdMcpSocketServer = createHTTPServer(selfIdMcpApp); let sttService: OpenAISTT | null = null; let ttsService: OpenAITTS | null = null; @@ -525,28 +411,7 @@ export async function createPaseoDaemon( const start = async () => { // Acquire PID lock - await acquirePidLock(config.paseoHome, selfIdMcpSocketPath); - - // Start Self-ID MCP socket server first - await new Promise((resolve, reject) => { - const onError = (err: Error) => { - selfIdMcpSocketServer.off("listening", onListening); - reject(err); - }; - const onListening = () => { - selfIdMcpSocketServer.off("error", onError); - logger.info({ path: selfIdMcpSocketPath }, `Self-ID MCP server listening on ${selfIdMcpSocketPath}`); - resolve(); - }; - selfIdMcpSocketServer.once("error", onError); - selfIdMcpSocketServer.once("listening", onListening); - - // Remove stale socket file if it exists - if (existsSync(selfIdMcpSocketPath)) { - unlinkSync(selfIdMcpSocketPath); - } - selfIdMcpSocketServer.listen(selfIdMcpSocketPath); - }); + await acquirePidLock(config.paseoHome, config.listen); // Start main HTTP server await new Promise((resolve, reject) => { @@ -626,16 +491,10 @@ export async function createPaseoDaemon( await new Promise((resolve) => { httpServer.close(() => resolve()); }); - await new Promise((resolve) => { - selfIdMcpSocketServer.close(() => resolve()); - }); // Clean up socket files if (listenTarget.type === "socket" && existsSync(listenTarget.path)) { unlinkSync(listenTarget.path); } - if (existsSync(selfIdMcpSocketPath)) { - unlinkSync(selfIdMcpSocketPath); - } // Release PID lock await releasePidLock(config.paseoHome); }; diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 6d06b524f..7a0fe71b3 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -15,9 +15,6 @@ function getDefaultListen(): string { return `127.0.0.1:${DEFAULT_PORT}`; } -function getSelfIdMcpSocketPath(paseoHome: string): string { - return path.join(paseoHome, "self-id-mcp.sock"); -} function parseOpenAIConfig(env: NodeJS.ProcessEnv) { const apiKey = env.OPENAI_API_KEY; @@ -82,7 +79,6 @@ export function loadConfig( // Default is TCP at 127.0.0.1:6767 const listen = env.PASEO_LISTEN ?? persisted.listen ?? getDefaultListen(); const mcpListen = getListenForMcp(listen); - const selfIdMcpSocketPath = getSelfIdMcpSocketPath(paseoHome); const envCorsOrigins = env.PASEO_CORS_ORIGINS ? env.PASEO_CORS_ORIGINS.split(",").map((s) => s.trim()) @@ -91,7 +87,6 @@ export function loadConfig( return { listen, paseoHome, - selfIdMcpSocketPath, corsAllowedOrigins: [...persisted.cors.allowedOrigins, ...envCorsOrigins], agentMcpRoute: DEFAULT_AGENT_MCP_ROUTE, agentMcpAllowedHosts: [mcpListen, `localhost:${mcpListen.split(":")[1]}`], diff --git a/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts index f44a0b88b..a62e239ee 100644 --- a/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts @@ -3,8 +3,6 @@ import { mkdtempSync, writeFileSync, rmSync, existsSync, realpathSync } from "fs import { tmpdir } from "os"; import path from "path"; import { execSync } from "child_process"; -import { experimental_createMCPClient } from "ai"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { createDaemonTestContext, @@ -30,63 +28,6 @@ function hasGitHubCliAuth(): boolean { const testWithGitHubCliAuth = hasGitHubCliAuth() ? test : test.skip; -type McpToolResult = { - structuredContent?: Record; - content?: Array<{ structuredContent?: Record } | Record>; - toolResult?: unknown; - isError?: boolean; -}; - -type McpClient = { - callTool: (input: { name: string; args?: Record }) => Promise; - close: () => Promise; -}; - -function getStructuredContent(result: McpToolResult): Record | null { - if (result.structuredContent && typeof result.structuredContent === "object") { - return result.structuredContent; - } - const content = result.content?.[0]; - if (content && "structuredContent" in content && content.structuredContent) { - return content.structuredContent; - } - if (content && typeof content === "object") { - return content; - } - return null; -} - -function getToolResultText(result: McpToolResult): string { - const chunks: string[] = []; - if (result.structuredContent) { - chunks.push(JSON.stringify(result.structuredContent)); - } - if (result.toolResult !== undefined) { - chunks.push(JSON.stringify(result.toolResult)); - } - for (const entry of result.content ?? []) { - if (entry && typeof entry === "object") { - if ("text" in entry && typeof (entry as { text?: unknown }).text === "string") { - chunks.push(String((entry as { text?: unknown }).text)); - } - if ( - "structuredContent" in entry && - (entry as { structuredContent?: unknown }).structuredContent - ) { - chunks.push(JSON.stringify((entry as { structuredContent?: unknown }).structuredContent)); - } - } - } - return chunks.join(" ").trim(); -} - -async function createMcpClient(port: number, agentId: string): Promise { - const url = new URL(`http://127.0.0.1:${port}/mcp/agents`); - url.searchParams.set("callerAgentId", agentId); - const transport = new StreamableHTTPClientTransport(url); - return (await experimental_createMCPClient({ transport })) as McpClient; -} - function initGitRepo(repoDir: string): void { execSync("git init -b main", { cwd: repoDir, stdio: "pipe" }); execSync("git config user.email 'paseo-test@example.com'", { @@ -439,49 +380,4 @@ describe("daemon checkout ship loop", () => { 60000 ); - test( - "set_branch is rejected outside Paseo-owned worktrees", - async () => { - const repoDir = tmpCwd("checkout-ship-non-paseo-"); - let agentId: string | null = null; - let mcpClient: McpClient | null = null; - - try { - initGitRepo(repoDir); - - const agent = await ctx.client.createAgent({ - provider: "codex", - model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, - cwd: repoDir, - title: "Checkout Non-Paseo", - }); - agentId = agent.id; - - mcpClient = await createMcpClient(ctx.daemon.port, agent.id); - let errorMessage = ""; - try { - const result = (await mcpClient.callTool({ - name: "set_branch", - args: { name: "not-allowed" }, - })) as McpToolResult; - errorMessage = getToolResultText(result); - } catch (error) { - errorMessage = error instanceof Error ? error.message : String(error); - } - expect(errorMessage).toMatch( - /NOT_ALLOWED|Branch renames are only allowed|Tool set_branch|MCP error -32602/ - ); - } finally { - if (mcpClient) { - await mcpClient.close().catch(() => undefined); - } - if (agentId) { - await ctx.client.deleteAgent(agentId).catch(() => undefined); - } - rmSync(repoDir, { recursive: true, force: true }); - } - }, - 60000 - ); }); diff --git a/packages/server/src/server/daemon-e2e/self-id-mcp.e2e.test.ts b/packages/server/src/server/daemon-e2e/self-id-mcp.e2e.test.ts deleted file mode 100644 index e6930a686..000000000 --- a/packages/server/src/server/daemon-e2e/self-id-mcp.e2e.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, test, expect, beforeEach, afterEach } from "vitest"; -import { - createDaemonTestContext, - type DaemonTestContext, -} from "../test-utils/index.js"; - -describe("self-id MCP e2e", () => { - let ctx: DaemonTestContext; - - beforeEach(async () => { - ctx = await createDaemonTestContext(); - }); - - afterEach(async () => { - await ctx.cleanup(); - }, 60000); - - test("UI agent can call set_title to change its title", async () => { - // Create a Claude agent with ui=true label (triggers MCP injection) - const agent = await ctx.client.createAgent({ - provider: "claude", - cwd: "/tmp", - title: "Initial Title", - labels: { ui: "true" }, - }); - - expect(agent.id).toBeTruthy(); - expect(agent.title).toBe("Initial Title"); - - // Send a message asking the agent to call set_title - await ctx.client.sendMessage( - agent.id, - "Use the set_title MCP tool to change your title to 'Updated via MCP'. Only call set_title, nothing else." - ); - - // Wait for permission request (default mode requires permission for MCP tools) - const state = await ctx.client.waitForFinish(agent.id, 60000); - expect(state.status).toBe("permission"); - expect(state.final?.pendingPermissions?.length).toBeGreaterThan(0); - expect(state.final?.pendingPermissions?.[0]?.name).toBe("mcp__paseo-self-id__set_title"); - - // Approve the permission - await ctx.client.respondToPermission(agent.id, state.final!.pendingPermissions![0]!.id, { - behavior: "allow", - }); - - // Wait for agent to complete - const finalState = await ctx.client.waitForFinish(agent.id, 60000); - expect(finalState.status).toBe("idle"); - expect(finalState.final?.title).toBe("Updated via MCP"); - }, 180000); -}); diff --git a/packages/server/src/server/daemon-e2e/setup.ts b/packages/server/src/server/daemon-e2e/setup.ts index 690475fbc..4913ccb05 100644 --- a/packages/server/src/server/daemon-e2e/setup.ts +++ b/packages/server/src/server/daemon-e2e/setup.ts @@ -1,3 +1,4 @@ +import "dotenv/config"; import { beforeAll, afterAll } from "vitest"; import { mkdtempSync } from "fs"; import { tmpdir } from "os"; diff --git a/packages/server/src/server/messages.test.ts b/packages/server/src/server/messages.test.ts index 3f0ce9025..1045e5c9c 100644 --- a/packages/server/src/server/messages.test.ts +++ b/packages/server/src/server/messages.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest"; import { serializeAgentStreamEvent } from "./messages.js"; describe("serializeAgentStreamEvent", () => { - test("strips leading paseo-instructions from user_message timeline items", () => { + test("preserves user_message text as-is", () => { const event = { type: "timeline", provider: "claude", @@ -15,22 +15,7 @@ describe("serializeAgentStreamEvent", () => { } as any; const serialized = serializeAgentStreamEvent(event) as any; - expect(serialized.item.text).toBe("Hello"); + expect(serialized.item.text).toBe(event.item.text); expect(serialized.item.messageId).toBe("m1"); }); - - test("does not strip non-leading tags", () => { - const event = { - type: "timeline", - provider: "claude", - item: { - type: "user_message", - text: "Hello \nX\n", - }, - } as any; - - const serialized = serializeAgentStreamEvent(event) as any; - expect(serialized.item.text).toBe(event.item.text); - }); }); - diff --git a/packages/server/src/server/messages.ts b/packages/server/src/server/messages.ts index e7700055a..dec134ebb 100644 --- a/packages/server/src/server/messages.ts +++ b/packages/server/src/server/messages.ts @@ -1,7 +1,6 @@ import type { ManagedAgent } from "./agent/agent-manager.js"; import { toAgentPayload } from "./agent/agent-projections.js"; import type { AgentStreamEvent } from "./agent/agent-sdk-types.js"; -import { stripLeadingPaseoInstructionTag } from "./agent/paseo-instructions-tag.js"; import type { AgentSnapshotPayload, AgentStreamEventPayload, @@ -34,15 +33,5 @@ export function serializeAgentStreamEvent( if (event.item.type !== "user_message") { return event as AgentStreamEventPayload; } - const stripped = stripLeadingPaseoInstructionTag(event.item.text); - if (stripped === event.item.text) { - return event as AgentStreamEventPayload; - } - return { - ...event, - item: { - ...event.item, - text: stripped, - }, - } as AgentStreamEventPayload; + return event as AgentStreamEventPayload; } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 67bd914e7..201439867 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -53,7 +53,7 @@ export type AgentMcpTransportFactory = () => Promise; import { buildProviderRegistry } from "./agent/provider-registry.js"; import { AgentManager } from "./agent/agent-manager.js"; import type { ManagedAgent } from "./agent/agent-manager.js"; -import { injectLeadingPaseoInstructionTag } from "./agent/paseo-instructions-tag.js"; +import { scheduleAgentMetadataGeneration } from "./agent/agent-metadata-generator.js"; import { toAgentPayload } from "./agent/agent-projections.js"; import { StructuredAgentResponseError, @@ -578,7 +578,7 @@ export class Session { }); } - // Title updates are now handled by set_title MCP tool calls. + // Title updates may be applied asynchronously after agent creation. }, { replayState: false } ); @@ -1402,14 +1402,20 @@ export class Session { const trimmedPrompt = initialPrompt?.trim(); if (trimmedPrompt) { + scheduleAgentMetadataGeneration({ + agentManager: this.agentManager, + agentId: snapshot.id, + cwd: snapshot.cwd, + initialPrompt: trimmedPrompt, + explicitTitle: snapshot.config.title, + paseoHome: this.paseoHome, + logger: this.sessionLogger, + }); + try { - const initialPromptWithInstructions = injectLeadingPaseoInstructionTag( - trimmedPrompt, - snapshot.config.paseoPromptInstructions - ); await this.handleSendAgentMessage( snapshot.id, - initialPromptWithInstructions, + trimmedPrompt, uuidv4(), images ); diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index 3f41eb0e6..b3df89ecd 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -3,7 +3,6 @@ import { readFileSync, writeFileSync, rmSync, readdirSync } from "node:fs"; import { appendFile, mkdir, readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import http from "node:http"; import type { AgentCapabilityFlags, AgentClient, @@ -32,141 +31,6 @@ const TEST_CAPABILITIES: AgentCapabilityFlags = { supportsToolInvocations: true, }; -type UnixHttpResponse = { - status: number; - headers: http.IncomingHttpHeaders; - body: string; -}; - -function parseSseDataFrames(body: string): string[] { - const frames: string[] = []; - const parts = body.split(/\n\n+/g); - for (const part of parts) { - const lines = part.split("\n"); - const dataLines: string[] = []; - for (const line of lines) { - if (line.startsWith("data:")) { - dataLines.push(line.slice("data:".length).trimStart()); - } - } - if (dataLines.length > 0) { - frames.push(dataLines.join("\n")); - } - } - return frames; -} - -function extractJsonRpcBody(res: UnixHttpResponse): unknown { - const contentType = String(res.headers["content-type"] ?? ""); - if (contentType.includes("text/event-stream")) { - const frames = parseSseDataFrames(res.body); - if (frames.length === 0) { - throw new Error("Empty SSE response from Self-ID MCP server"); - } - return JSON.parse(frames[frames.length - 1]!); - } - return JSON.parse(res.body); -} - -async function unixSocketJsonRpcRequest(params: { - socketPath: string; - path: string; - headers?: Record; - body: unknown; -}): Promise { - const bodyText = JSON.stringify(params.body); - return await new Promise((resolve, reject) => { - const req = http.request( - { - socketPath: params.socketPath, - path: params.path, - method: "POST", - headers: { - "Content-Type": "application/json", - "Content-Length": Buffer.byteLength(bodyText), - Accept: "application/json, text/event-stream", - ...(params.headers ?? {}), - }, - }, - (res) => { - const chunks: Buffer[] = []; - res.on("data", (chunk) => chunks.push(chunk)); - res.on("end", () => { - resolve({ - status: res.statusCode ?? 500, - headers: res.headers, - body: Buffer.concat(chunks).toString("utf-8"), - }); - }); - res.on("error", reject); - } - ); - req.on("error", reject); - req.write(bodyText); - req.end(); - }); -} - -async function callSelfIdMcpTool(params: { - socketPath: string; - callerAgentId: string; - toolName: "set_title"; - args: { title: string }; -}): Promise { - // Minimal MCP-over-HTTP (Unix socket) client, modeled after packages/server/src/self-id-bridge. - const urlPath = `/?callerAgentId=${encodeURIComponent(params.callerAgentId)}`; - - const initReq = { - jsonrpc: "2.0", - id: 1, - method: "initialize", - params: { - protocolVersion: "2024-11-05", - capabilities: {}, - clientInfo: { name: "fake-agent", version: "0.0.0" }, - }, - }; - - const initRes = await unixSocketJsonRpcRequest({ - socketPath: params.socketPath, - path: urlPath, - body: initReq, - }); - const mcpSessionId = typeof initRes.headers["mcp-session-id"] === "string" ? initRes.headers["mcp-session-id"] : null; - - let protocolVersion: string | null = null; - const initParsed = extractJsonRpcBody(initRes) as { result?: { protocolVersion?: string }; error?: { message?: string } }; - if (initParsed.error) { - throw new Error(initParsed.error.message ?? "Self-ID MCP initialize failed"); - } - protocolVersion = initParsed.result?.protocolVersion ?? null; - - const toolReq = { - jsonrpc: "2.0", - id: 2, - method: "tools/call", - params: { - name: params.toolName, - arguments: params.args, - }, - }; - - const headers: Record = {}; - if (mcpSessionId) headers["mcp-session-id"] = mcpSessionId; - if (protocolVersion) headers["mcp-protocol-version"] = protocolVersion; - - const toolRes = await unixSocketJsonRpcRequest({ - socketPath: params.socketPath, - path: urlPath, - headers, - body: toolReq, - }); - - const parsed = extractJsonRpcBody(toolRes) as { error?: { message?: string } }; - if (parsed.error) { - throw new Error(parsed.error.message ?? "Self-ID MCP tool call failed"); - } -} type Deferred = { promise: Promise; @@ -228,7 +92,7 @@ function buildToolCallForPrompt(provider: string, prompt: string) { const text = prompt.toLowerCase(); if (provider === "claude") { if (text.includes("read") && text.includes("/etc/hosts")) { - return { name: "Read", input: { path: "/etc/hosts" }, output: { lines: 7 } }; + return { name: "Read", input: { path: "/etc/hosts" }, output: undefined }; } if (text.includes("rm -f permission.txt")) { return { name: "Bash", input: { command: "rm -f permission.txt" }, output: { ok: true } }; @@ -242,9 +106,6 @@ function buildToolCallForPrompt(provider: string, prompt: string) { if (text.includes("edit") && text.includes(".txt")) { return { name: "Edit", input: { file: "test.txt" }, output: { applied: true } }; } - if (text.includes("set_title") && text.includes("mcp")) { - return { name: "mcp__paseo-self-id__set_title", input: { title: "Updated via MCP" }, output: { ok: true } }; - } return null; } @@ -253,10 +114,16 @@ function buildToolCallForPrompt(provider: string, prompt: string) { return { name: "shell", input: { command: "echo hello" }, output: { stdout: "hello\n" } }; } if (text.includes("read") && text.includes("/etc/hosts")) { - return { name: "read_file", input: { path: "/etc/hosts" }, output: { lines: 7 } }; + return { name: "read_file", input: { path: "/etc/hosts" }, output: undefined }; + } + if (text.includes("read") && text.includes("tool-create.txt")) { + return { name: "read_file", input: { path: "tool-create.txt" }, output: undefined }; } if (text.includes("edit") && text.includes(".txt")) { - return { name: "apply_patch", input: { patch: "*** Begin Patch\n*** End Patch\n" }, output: { applied: true } }; + const output = text.includes("tool-create.txt") + ? { applied: true, file: "tool-create.txt" } + : { applied: true }; + return { name: "apply_patch", input: { patch: "*** Begin Patch\n*** End Patch\n" }, output }; } const printfMatch = /printf\s+\"ok\"\s*>\s*([^\s`]+)/i.exec(text) ?? @@ -415,6 +282,21 @@ class FakeAgentSession implements AgentSession { await this.applyToolSideEffects(tool.name, tool.input ?? {}, textPrompt); + let toolOutput: unknown = tool.output; + if (!toolOutput && (tool.name === "Read" || tool.name === "read_file")) { + const pathInput = + typeof tool.input?.path === "string" ? tool.input.path : "/etc/hosts"; + const resolvedPath = path.isAbsolute(pathInput) + ? pathInput + : path.join(this.config.cwd ?? process.cwd(), pathInput); + try { + const content = readFileSync(resolvedPath, "utf8"); + toolOutput = { path: pathInput, content }; + } catch { + toolOutput = { path: pathInput, content: "" }; + } + } + const toolCompleted: AgentStreamEvent = { type: "timeline", provider: this.providerName, @@ -424,7 +306,7 @@ class FakeAgentSession implements AgentSession { callId, status: "completed", input: tool.input ?? undefined, - output: tool.output ?? { ok: true }, + output: toolOutput ?? { ok: true }, }, }; await this.appendHistoryEvent(toolCompleted); @@ -695,36 +577,21 @@ class FakeAgentSession implements AgentSession { return; } - if (toolName === "mcp__paseo-self-id__set_title") { - const title = typeof toolInput.title === "string" ? toolInput.title : null; - const server = (this.config.mcpServers as Record | undefined)?.["paseo-self-id"]; - const args = Array.isArray(server?.args) ? (server.args as string[]) : []; - const socketIndex = args.indexOf("--socket"); - const agentIndex = args.indexOf("--agent-id"); - const socketPath = socketIndex >= 0 ? args[socketIndex + 1] : null; - const callerAgentId = agentIndex >= 0 ? args[agentIndex + 1] : null; - - if (!title || !socketPath || !callerAgentId) { - throw new Error("FakeAgentSession missing paseo-self-id MCP config"); - } - - await callSelfIdMcpTool({ - socketPath, - callerAgentId, - toolName: "set_title", - args: { title }, - }); - return; - } - if (toolName === "Edit" || toolName === "apply_patch") { + const lowerPrompt = prompt.toLowerCase(); const match = /edit the file\s+([^\s]+)\s+and change/i.exec(prompt); - const filePath = match?.[1]; + const filePath = match?.[1] ?? (lowerPrompt.includes("tool-create.txt") ? "tool-create.txt" : null); if (filePath) { try { - const before = readFileSync(filePath, "utf8"); - const after = before.replace(/hello/g, "goodbye"); - writeFileSync(filePath, after); + const resolved = path.isAbsolute(filePath) + ? filePath + : path.join(this.config.cwd ?? process.cwd(), filePath); + const before = readFileSync(resolved, "utf8"); + let after = before.replace(/hello/g, "goodbye"); + if (lowerPrompt.includes("alpha") && lowerPrompt.includes("beta")) { + after = after.replace(/alpha/g, "beta"); + } + writeFileSync(resolved, after); } catch { // ignore } diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts index cd2341508..85f1cedd0 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.ts @@ -65,7 +65,6 @@ export async function createTestPaseoDaemon( const config: PaseoDaemonConfig = { listen: `${listenHost}:${port}`, paseoHome, - selfIdMcpSocketPath: path.join(paseoHome, "self-id-mcp.sock"), corsAllowedOrigins: options.corsAllowedOrigins ?? [], agentMcpRoute: "/mcp/agents", agentMcpAllowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`, `${listenHost}:${port}`],