diff --git a/packages/app/e2e/browser/paseo-tool-call-details.codex.real.spec.ts b/packages/app/e2e/browser/paseo-tool-call-details.codex.real.spec.ts new file mode 100644 index 0000000000..87afd783a4 --- /dev/null +++ b/packages/app/e2e/browser/paseo-tool-call-details.codex.real.spec.ts @@ -0,0 +1,124 @@ +import { mkdirSync, mkdtempSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { Locator, Page, TestInfo } from "@playwright/test"; +import { expect, test } from "../support/fixtures"; +import { + cleanupRewindFlow, + launchAgent, + sendMessage, + type AgentHandle, +} from "../support/helpers/rewind-flow"; + +const GREETING = "Say hello back."; + +test.use({ e2eInjectPaseoTools: true }); + +function verificationPrompt(workspacePath: string): string { + return [ + "Use Paseo tools to perform this exact UI verification.", + "Do not edit files or run shell commands. Every prompt below is intentionally non-actionable.", + `1. Create a local workspace at ${workspacePath} titled "Presentation QA workspace".`, + `2. In that workspace, create a codex/gpt-5.4-mini agent titled "Greeting child" with the initial prompt "${GREETING}".`, + `3. Create a schedule named "Greeting schedule" with prompt "${GREETING}", cron "0 0 1 1 *", timezone "Europe/Berlin", provider "codex/gpt-5.4-mini", cwd ${workspacePath}, maxRuns 1, and expiresIn "1h". Delete that schedule using its returned id.`, + `4. Create a heartbeat named "Greeting heartbeat" with prompt "${GREETING}", cron "0 0 1 1 *", timezone "Europe/Berlin", maxRuns 1, and expiresIn "1h". Delete that heartbeat using its returned id.`, + `5. After the child agent finishes, send it the follow-up prompt "${GREETING}" synchronously.`, + "6. Archive the workspace from step 1.", + "7. Reply with exactly TOOL_CALL_QA_DONE.", + "Use each named Paseo tool exactly once except for the matching schedule and heartbeat deletion tools.", + ].join(" "); +} + +async function expandedToolCall(page: Page, label: string): Promise { + const badge = page.getByTestId("tool-call-badge").filter({ hasText: label }).last(); + await expect(badge).toBeVisible({ timeout: 30_000 }); + await badge.scrollIntoViewIfNeeded(); + await badge.click(); + return badge; +} + +async function captureToolCall( + page: Page, + testInfo: TestInfo, + input: { label: string; artifact: string; expectedText: Array }, +): Promise { + const badge = await expandedToolCall(page, input.label); + for (const text of input.expectedText) { + await expect(badge).toContainText(text); + } + const screenshot = testInfo.outputPath(`${input.artifact}.png`); + await badge.screenshot({ path: screenshot }); + await testInfo.attach(input.artifact, { path: screenshot, contentType: "image/png" }); + await badge.click(); +} + +async function runPresentationJourney(handle: AgentHandle, workspacePath: string): Promise { + await sendMessage(handle, verificationPrompt(workspacePath)); + await expect( + handle.page.getByTestId("assistant-message").filter({ hasText: "TOOL_CALL_QA_DONE" }).last(), + ).toBeVisible({ timeout: 30_000 }); +} + +test.describe("real Codex Paseo tool-call presentation", () => { + test.setTimeout(600_000); + + test("renders owned tool inputs and results as readable text", async ({ page }, testInfo) => { + const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-tool-call-presentation-"))); + const childWorkspacePath = path.join(cwd, "presentation-workspace"); + mkdirSync(childWorkspacePath); + let handle: AgentHandle | undefined; + + try { + handle = await launchAgent({ page, provider: "codex", cwd, mode: "full-access" }); + await runPresentationJourney(handle, childWorkspacePath); + await page.setViewportSize({ width: 1280, height: 1600 }); + + await captureToolCall(page, testInfo, { + label: "Create workspace", + artifact: "create-workspace-presentation", + expectedText: ["Details", "Presentation QA workspace", childWorkspacePath, "local"], + }); + await captureToolCall(page, testInfo, { + label: "Create agent", + artifact: "create-agent-prompt-presentation", + expectedText: ["Prompt", GREETING, "Greeting child", "codex/gpt-5.4-mini", "Result"], + }); + await captureToolCall(page, testInfo, { + label: "Send agent prompt", + artifact: "send-prompt-presentation", + expectedText: ["Prompt", GREETING, "Agent", "Result"], + }); + await captureToolCall(page, testInfo, { + label: "Create schedule", + artifact: "schedule-presentation", + expectedText: [ + "Prompt", + GREETING, + "0 0 1 1 *", + "Europe/Berlin", + "Greeting schedule", + "Result", + ], + }); + await captureToolCall(page, testInfo, { + label: "Create heartbeat", + artifact: "heartbeat-presentation", + expectedText: [ + "Prompt", + GREETING, + "0 0 1 1 *", + "Europe/Berlin", + "Greeting heartbeat", + "Result", + ], + }); + await captureToolCall(page, testInfo, { + label: "Archive workspace", + artifact: "archive-workspace-presentation", + expectedText: ["Details", "Workspace", "Result", "Removed directory"], + }); + } finally { + await cleanupRewindFlow({ handle, cwd }); + } + }); +}); diff --git a/packages/app/e2e/support/fixtures.ts b/packages/app/e2e/support/fixtures.ts index d032a0f1a3..9fe122762c 100644 --- a/packages/app/e2e/support/fixtures.ts +++ b/packages/app/e2e/support/fixtures.ts @@ -34,13 +34,20 @@ const metroTest = base.extend({ const daemonTest = metroTest.extend< { projectOwnership: void }, - { e2eForkProviders: string[]; e2eWorker: void; e2eWorkerClient: SeedDaemonClient } + { + e2eForkProviders: string[]; + e2eInjectPaseoTools: boolean; + e2eWorker: void; + e2eWorkerClient: SeedDaemonClient; + } >({ e2eForkProviders: [[], { scope: "worker", option: true }], + e2eInjectPaseoTools: [false, { scope: "worker", option: true }], e2eWorker: [ - async ({ e2eForkProviders }, provide, workerInfo) => { + async ({ e2eForkProviders, e2eInjectPaseoTools }, provide, workerInfo) => { const worker = await startE2EWorker(workerInfo.workerIndex, { forkProviders: e2eForkProviders, + injectPaseoTools: e2eInjectPaseoTools, }); try { await provide(); diff --git a/packages/app/e2e/support/helpers/e2e-worker.ts b/packages/app/e2e/support/helpers/e2e-worker.ts index 2d61f50af5..a5ee080ff9 100644 --- a/packages/app/e2e/support/helpers/e2e-worker.ts +++ b/packages/app/e2e/support/helpers/e2e-worker.ts @@ -1,4 +1,5 @@ import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -155,7 +156,7 @@ async function applyMetadataFork(targetHome: string, providerIds: string[]): Pro export async function startE2EWorker( workerIndex: number, - options: { forkProviders?: string[] } = {}, + options: { forkProviders?: string[]; injectPaseoTools?: boolean } = {}, ): Promise { const requestedRoot = resolveOptionalHome(process.env.E2E_PASEO_HOME); const paseoHome = requestedRoot @@ -168,6 +169,9 @@ export async function startE2EWorker( try { await applyMetadataFork(paseoHome, options.forkProviders ?? []); + if (options.injectPaseoTools) { + await enablePaseoTools(paseoHome); + } const daemon = await startIsolatedHostDaemon(serverId, { paseoHome, preserveHome, @@ -201,3 +205,28 @@ export async function startE2EWorker( throw error; } } + +async function enablePaseoTools(paseoHome: string): Promise { + const configPath = path.join(paseoHome, "config.json"); + const existing = existsSync(configPath) + ? JSON.parse(await readFile(configPath, "utf8")) + : { version: 1 }; + await writeFile( + configPath, + `${JSON.stringify( + { + ...existing, + daemon: { + ...existing.daemon, + mcp: { + ...existing.daemon?.mcp, + enabled: true, + injectIntoAgents: true, + }, + }, + }, + null, + 2, + )}\n`, + ); +} diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index a44cfe592e..59079efee2 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -3146,6 +3146,7 @@ export const ToolCall = memo(function ToolCall({ const handleToggle = useCallback(() => { if (!shouldRenderInline) { openToolCall({ + toolName, displayName: presentation.displayName, summary: presentation.summary, detail: effectiveDetail, @@ -3159,6 +3160,7 @@ export const ToolCall = memo(function ToolCall({ }, [ shouldRenderInline, openToolCall, + toolName, presentation.displayName, presentation.summary, presentation.errorText, @@ -3199,6 +3201,7 @@ export const ToolCall = memo(function ToolCall({ if (!shouldRenderInline) return null; return ( + {section.title} + {section.kind === "prose" ? ( + + {section.text} + + ) : ( + + {section.fields.map((field) => ( + + {field.label} + + {field.value} + + + ))} + + )} + + ); +} + +function buildPaseoUnknownSections( + toolName: string | undefined, + detail: UnknownDetail, +): ReactNode[] | null { + if (!toolName) return null; + const sections = buildPaseoToolDetailSections(toolName, detail.input, detail.output); + if (!sections) return null; + return sections.map((section) => ); +} + function buildDetailSections( + toolName: string | undefined, detail: ToolCallDetail | undefined, diffLines: DiffLine[] | undefined, ds: DetailStyles, @@ -701,7 +741,7 @@ function buildDetailSections( return []; } if (detail.type === "unknown") { - return buildUnknownSections(detail, ds, t); + return buildPaseoUnknownSections(toolName, detail) ?? buildUnknownSections(detail, ds, t); } return []; } @@ -741,6 +781,7 @@ function LoadingSkeleton({ containerStyle }: { containerStyle: StyleProp); @@ -796,6 +837,46 @@ const styles = StyleSheet.create((theme) => { fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, }, + paseoSection: { + gap: theme.spacing[3], + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[4], + borderBottomWidth: theme.borderWidth[1], + borderBottomColor: theme.colors.border, + }, + paseoSectionTitle: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.medium, + }, + paseoProse: { + color: theme.colors.foreground, + fontSize: theme.fontSize.content, + lineHeight: Math.round(theme.fontSize.content * 1.5), + overflowWrap: "anywhere", + }, + paseoFields: { + gap: theme.spacing[3], + }, + paseoFieldRow: { + flexDirection: "row", + alignItems: "flex-start", + gap: theme.spacing[4], + }, + paseoFieldLabel: { + width: 120, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + lineHeight: Math.round(theme.fontSize.base * 1.5), + }, + paseoFieldValue: { + flex: 1, + minWidth: 0, + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + lineHeight: Math.round(theme.fontSize.base * 1.5), + overflowWrap: "anywhere", + }, section: { gap: theme.spacing[2], }, diff --git a/packages/app/src/components/tool-call-sheet.tsx b/packages/app/src/components/tool-call-sheet.tsx index 4c7a16e22a..022a9bc7c2 100644 --- a/packages/app/src/components/tool-call-sheet.tsx +++ b/packages/app/src/components/tool-call-sheet.tsx @@ -17,6 +17,7 @@ import { ToolCallDetailsContent } from "./tool-call-details"; // ----- Types ----- export interface ToolCallSheetData { + toolName: string; displayName: string; summary?: string; detail?: ToolCallDetail; @@ -155,7 +156,14 @@ interface ToolCallSheetContentProps { function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) { const { t } = useTranslation(); - const { displayName, detail, errorText, icon: IconComponent, showLoadingSkeleton } = data; + const { + toolName, + displayName, + detail, + errorText, + icon: IconComponent, + showLoadingSkeleton, + } = data; return ( @@ -181,6 +189,7 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) { {/* Content */} { }); expect(presentation).toMatchObject({ - displayName: "Exec Command", + displayName: "Exec command", icon: fakeIcons.wrench, isLoadingDetails: true, hasDetails: false, diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index 580856fbfd..f81e375b08 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -924,7 +924,7 @@ describe("stream reducer canonical tool calls", () => { detail: tool.payload.data.detail, }); assert.strictEqual(display.summary, undefined); - assert.strictEqual(display.displayName, "Exec Command"); + assert.strictEqual(display.displayName, "Exec command"); }); it("preserves early input when later updates contain null input", () => { diff --git a/packages/app/src/utils/tool-call-display.test.ts b/packages/app/src/utils/tool-call-display.test.ts index 4347da799c..557ad32e39 100644 --- a/packages/app/src/utils/tool-call-display.test.ts +++ b/packages/app/src/utils/tool-call-display.test.ts @@ -70,7 +70,7 @@ describe("tool-call-display", () => { }); expect(display).toEqual({ - displayName: "Custom Tool Name", + displayName: "Custom tool name", }); }); @@ -98,7 +98,7 @@ describe("tool-call-display", () => { }); expect(display).toEqual({ - displayName: "Worktree Setup", + displayName: "Worktree setup", summary: "feature-branch", }); }); @@ -116,7 +116,7 @@ describe("tool-call-display", () => { }); expect(display).toEqual({ - displayName: "Exec Command", + displayName: "Exec command", }); }); diff --git a/packages/protocol/src/paseo-tool-call-detail.test.ts b/packages/protocol/src/paseo-tool-call-detail.test.ts new file mode 100644 index 0000000000..25a637e45c --- /dev/null +++ b/packages/protocol/src/paseo-tool-call-detail.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vitest"; + +import { buildPaseoToolDetailSections } from "./paseo-tool-call-detail.js"; + +describe("Paseo tool-call detail presentation", () => { + it.each(["mcp__paseo__create_agent", "paseo.create_agent", "paseo_remote.create_agent"])( + "shares one create-agent mapping for %s", + (toolName) => { + expect( + buildPaseoToolDetailSections( + toolName, + { + workspaceId: "wks_123", + provider: "codex/gpt-5.4", + title: "Greeter", + initialPrompt: "Say hello back.\nDo nothing else.", + notifyOnFinish: true, + }, + { agentId: "agt_123", status: "idle" }, + ), + ).toEqual([ + { + kind: "prose", + title: "Prompt", + text: "Say hello back.\nDo nothing else.", + }, + { + kind: "fields", + title: "Details", + fields: [ + { label: "Title", value: "Greeter" }, + { label: "Provider", value: "codex/gpt-5.4" }, + { label: "Workspace", value: "wks_123" }, + { label: "Notify on finish", value: "Yes" }, + ], + }, + { + kind: "fields", + title: "Result", + fields: [ + { label: "Agent", value: "agt_123" }, + { label: "Status", value: "idle" }, + ], + }, + ]); + }, + ); + + it("formats schedule cadence and nested settings without JSON syntax", () => { + const sections = buildPaseoToolDetailSections( + "mcp__paseo__create_schedule", + { + prompt: "Say hello back.", + cron: "0 9 * * 1", + timezone: "Europe/Berlin", + provider: "codex/gpt-5.4", + maxRuns: 1, + }, + { + id: "sch_123", + status: "active", + nextRunAt: "2026-09-07T09:00:00.000Z", + target: { type: "new-agent", mode: "read-only" }, + }, + ); + + expect(sections?.slice(0, 2)).toMatchObject([ + { kind: "prose", title: "Prompt", text: "Say hello back." }, + { + kind: "fields", + title: "Details", + fields: [ + { label: "Cron", value: "0 9 * * 1" }, + { label: "Timezone", value: "Europe/Berlin" }, + { label: "Provider", value: "codex/gpt-5.4" }, + { label: "Maximum runs", value: "1" }, + ], + }, + ]); + expect(JSON.stringify(sections)).not.toContain('\\"new-agent\\"'); + expect(sections?.at(-1)).toEqual({ + kind: "fields", + title: "Result", + fields: [ + { label: "ID", value: "sch_123" }, + { label: "Status", value: "active" }, + { label: "Next run", value: "2026-09-07T09:00:00.000Z" }, + ], + }); + }); + + it("unwraps MCP result envelopes instead of exposing JSON-encoded text", () => { + expect( + buildPaseoToolDetailSections( + "mcp__paseo__send_agent_prompt", + { prompt: "Say hello back." }, + { + meta: null, + content: [ + { + type: "text", + text: '{"success":true,"status":"idle","lastMessage":"Hello back."}', + }, + ], + structuredContent: { + success: true, + status: "idle", + lastMessage: "Hello back.", + }, + }, + )?.at(-1), + ).toEqual({ + kind: "fields", + title: "Result", + fields: [ + { label: "Status", value: "idle" }, + { label: "Last message", value: "Hello back." }, + ], + }); + }); + + it("uses readable fallback fields for newly added Paseo tools", () => { + expect( + buildPaseoToolDetailSections( + "mcp__paseo__future_tool", + { opaqueThing: ["one", "two"], enabled: false }, + { success: true }, + ), + ).toEqual([ + { + kind: "fields", + title: "Details", + fields: [ + { label: "Enabled", value: "No" }, + { label: "Opaque thing", value: "• one\n• two" }, + ], + }, + { + kind: "fields", + title: "Result", + fields: [{ label: "Success", value: "Yes" }], + }, + ]); + }); + + it("leaves non-Paseo tools alone", () => { + expect(buildPaseoToolDetailSections("mcp__github__create_issue", {}, {})).toBeNull(); + }); +}); diff --git a/packages/protocol/src/paseo-tool-call-detail.ts b/packages/protocol/src/paseo-tool-call-detail.ts new file mode 100644 index 0000000000..a922a78442 --- /dev/null +++ b/packages/protocol/src/paseo-tool-call-detail.ts @@ -0,0 +1,355 @@ +import { getPaseoToolLeafName } from "./tool-name-normalization.js"; + +export interface PaseoToolDetailField { + label: string; + value: string; +} + +export type PaseoToolDetailSection = + | { + kind: "prose"; + title: string; + text: string; + } + | { + kind: "fields"; + title: string; + fields: PaseoToolDetailField[]; + }; + +interface ToolDetailSpec { + promptField?: string; + inputOrder?: readonly string[]; + outputFields?: readonly string[]; +} + +const WORKSPACE_FIELDS = [ + "title", + "workspaceId", + "projectId", + "isolation", + "path", + "mode", + "worktreeSlug", + "branchName", + "baseBranch", + "branch", + "prNumber", + "forge", +] as const; +const AGENT_FIELDS = [ + "title", + "agentId", + "provider", + "workspaceId", + "cwd", + "sessionMode", + "modeId", + "background", + "notifyOnFinish", + "settings", + "labels", +] as const; +const AUTOMATION_FIELDS = [ + "name", + "id", + "cron", + "timezone", + "provider", + "cwd", + "isolation", + "maxRuns", + "expiresIn", + "clearExpires", +] as const; +const BROWSER_FIELDS = [ + "browserId", + "url", + "ref", + "sourceRef", + "targetRef", + "value", + "text", + "key", + "button", + "doubleClick", + "modifiers", + "filePaths", + "fullPage", + "maxEntries", + "timeoutMs", + "deltaX", + "deltaY", + "width", + "height", + "function", +] as const; + +const TOOL_SPECS: Readonly> = { + create_workspace: { + inputOrder: WORKSPACE_FIELDS, + outputFields: ["workspaceId", "projectId"], + }, + list_workspaces: {}, + archive_workspace: { + inputOrder: ["workspaceId"], + outputFields: ["workspaceId", "archivedAgentIds", "removedDirectory"], + }, + rename_workspace: { inputOrder: ["title", "workspaceId"] }, + create_agent: { + promptField: "initialPrompt", + inputOrder: AGENT_FIELDS, + outputFields: ["agentId", "status", "currentModeId", "cwd"], + }, + send_agent_prompt: { + promptField: "prompt", + inputOrder: AGENT_FIELDS, + outputFields: ["status", "lastMessage", "permission"], + }, + get_agent_status: { inputOrder: ["agentId"] }, + list_agents: { inputOrder: ["cwd", "statuses", "sinceHours", "limit", "includeArchived"] }, + cancel_agent: { inputOrder: ["agentId"] }, + archive_agent: { inputOrder: ["agentId"] }, + kill_agent: { inputOrder: ["agentId"] }, + update_agent: { inputOrder: AGENT_FIELDS }, + get_agent_activity: { inputOrder: ["agentId", "limit"] }, + set_agent_mode: { inputOrder: ["agentId", "modeId"] }, + list_workspace_scripts: { inputOrder: ["workspaceId"] }, + start_workspace_script: { inputOrder: ["workspaceId", "scriptName"] }, + stop_workspace_script: { inputOrder: ["workspaceId", "scriptName"] }, + list_terminals: { inputOrder: ["cwd", "all"] }, + create_terminal: { inputOrder: ["cwd", "workspaceId", "name", "command"] }, + kill_terminal: { inputOrder: ["terminalId"] }, + capture_terminal: { inputOrder: ["terminalId", "lines"] }, + send_terminal_keys: { inputOrder: ["terminalId", "keys", "literal"] }, + create_schedule: { + promptField: "prompt", + inputOrder: AUTOMATION_FIELDS, + outputFields: ["id", "status", "nextRunAt", "expiresAt"], + }, + create_heartbeat: { + promptField: "prompt", + inputOrder: AUTOMATION_FIELDS, + outputFields: ["id", "status", "nextRunAt", "expiresAt"], + }, + delete_heartbeat: { inputOrder: ["id"] }, + list_schedules: {}, + inspect_schedule: { inputOrder: ["id"] }, + pause_schedule: { inputOrder: ["id"] }, + resume_schedule: { inputOrder: ["id"] }, + delete_schedule: { inputOrder: ["id"] }, + update_schedule: { promptField: "prompt", inputOrder: AUTOMATION_FIELDS }, + schedule_logs: { inputOrder: ["id"] }, + run_schedule_once: { inputOrder: ["id"] }, + list_providers: {}, + list_models: { inputOrder: ["provider"] }, + list_profiles: {}, + inspect_provider: { inputOrder: ["provider", "cwd", "settings"] }, + list_pending_permissions: {}, + respond_to_permission: { inputOrder: ["agentId", "requestId", "response"] }, + browser_list_tabs: {}, + browser_new_tab: { inputOrder: BROWSER_FIELDS }, + browser_snapshot: { inputOrder: BROWSER_FIELDS }, + browser_click: { inputOrder: BROWSER_FIELDS }, + browser_fill: { inputOrder: BROWSER_FIELDS }, + browser_wait: { inputOrder: BROWSER_FIELDS }, + browser_type: { inputOrder: BROWSER_FIELDS }, + browser_keypress: { inputOrder: BROWSER_FIELDS }, + browser_navigate: { inputOrder: BROWSER_FIELDS }, + browser_back: { inputOrder: BROWSER_FIELDS }, + browser_forward: { inputOrder: BROWSER_FIELDS }, + browser_reload: { inputOrder: BROWSER_FIELDS }, + browser_screenshot: { inputOrder: BROWSER_FIELDS }, + browser_upload: { inputOrder: BROWSER_FIELDS }, + browser_hover: { inputOrder: BROWSER_FIELDS }, + browser_select: { inputOrder: BROWSER_FIELDS }, + browser_drag: { inputOrder: BROWSER_FIELDS }, + browser_logs: { inputOrder: BROWSER_FIELDS }, + browser_evaluate: { inputOrder: BROWSER_FIELDS }, + browser_scroll: { inputOrder: BROWSER_FIELDS }, + browser_resize: { inputOrder: BROWSER_FIELDS }, + browser_close_tab: { inputOrder: BROWSER_FIELDS }, +}; + +const FIELD_LABELS: Readonly> = { + agentId: "Agent", + archivedAgentIds: "Archived agents", + baseBranch: "Base branch", + branchName: "New branch", + browserId: "Browser tab", + clearExpires: "Clear expiry", + currentModeId: "Current mode", + cwd: "Working directory", + deltaX: "Horizontal delta", + deltaY: "Vertical delta", + doubleClick: "Double click", + expiresIn: "Expires in", + expiresAt: "Expires", + filePaths: "Files", + fullPage: "Full page", + initialPrompt: "Prompt", + id: "ID", + lastMessage: "Last message", + maxEntries: "Maximum entries", + maxRuns: "Maximum runs", + modeId: "Mode", + newMode: "New mode", + nextRunAt: "Next run", + notifyOnFinish: "Notify on finish", + prNumber: "Change request", + projectId: "Project", + removedDirectory: "Removed directory", + requestId: "Request", + scriptName: "Script", + sessionMode: "Session mode", + sinceHours: "Since (hours)", + sourceRef: "Source", + targetRef: "Target", + terminalId: "Terminal", + thinkingOptionId: "Thinking", + timeoutMs: "Timeout (ms)", + updateCount: "Updates", + workspaceId: "Workspace", + worktreeSlug: "Worktree", +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function humanizeKey(key: string): string { + const known = FIELD_LABELS[key]; + if (known) return known; + const words = key + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/[._-]+/g, " ") + .split(" ") + .filter(Boolean); + const sentence = words.join(" ").toLowerCase(); + return `${sentence[0]?.toUpperCase() ?? ""}${sentence.slice(1)}`; +} + +function formatValue(value: unknown, depth = 0): string { + if (value === null) return "None"; + if (value === undefined) return ""; + if (typeof value === "boolean") return value ? "Yes" : "No"; + if (typeof value === "string" || typeof value === "number" || typeof value === "bigint") { + return String(value); + } + if (Array.isArray(value)) { + if (value.length === 0) return "None"; + return value.map((item) => `• ${indentMultiline(formatValue(item, depth + 1), 2)}`).join("\n"); + } + if (isRecord(value)) { + const entries = Object.entries(value).filter(([, child]) => child !== undefined); + if (entries.length === 0) return "None"; + return entries + .map(([key, child]) => { + const formatted = formatValue(child, depth + 1); + return `${humanizeKey(key)}: ${indentMultiline(formatted, 2)}`; + }) + .join("\n"); + } + return String(value); +} + +function indentMultiline(value: string, spaces: number): string { + const indentation = " ".repeat(spaces); + return value.replace(/\n/g, `\n${indentation}`); +} + +function orderedEntries( + value: Record, + order: readonly string[] = [], + omittedKey?: string, + includedKeys?: readonly string[], +): Array<[string, unknown]> { + const included = includedKeys ? new Set(includedKeys) : null; + const keys = Object.keys(value).filter( + (key) => key !== omittedKey && value[key] !== undefined && (!included || included.has(key)), + ); + const rank = new Map(order.map((key, index) => [key, index])); + keys.sort((left, right) => { + const leftRank = rank.get(left) ?? Number.MAX_SAFE_INTEGER; + const rightRank = rank.get(right) ?? Number.MAX_SAFE_INTEGER; + return leftRank - rightRank || left.localeCompare(right); + }); + return keys.map((key) => [key, value[key]]); +} + +function fieldsFromValue( + value: unknown, + order?: readonly string[], + omittedKey?: string, + includedKeys?: readonly string[], +): PaseoToolDetailField[] { + if (value === null || value === undefined) return []; + if (!isRecord(value)) { + const formatted = formatValue(value); + return formatted ? [{ label: "Value", value: formatted }] : []; + } + return orderedEntries(value, order, omittedKey, includedKeys).map(([key, child]) => ({ + label: humanizeKey(key), + value: formatValue(child), + })); +} + +function parseJsonText(value: unknown): unknown { + if (typeof value !== "string") return undefined; + try { + return JSON.parse(value) as unknown; + } catch { + return undefined; + } +} + +function unwrapMcpResult(output: unknown): unknown { + if (!isRecord(output)) return output; + + if (output.structuredContent !== undefined) { + return output.structuredContent; + } + + if (Array.isArray(output.content) && output.content.length === 1) { + const item = output.content[0]; + if (isRecord(item) && item.type === "text") { + return parseJsonText(item.text) ?? item.text; + } + } + + return output; +} + +export function buildPaseoToolDetailSections( + toolName: string, + input: unknown, + output: unknown, +): PaseoToolDetailSection[] | null { + const leafName = getPaseoToolLeafName(toolName); + if (!leafName) return null; + + const spec = TOOL_SPECS[leafName] ?? {}; + const sections: PaseoToolDetailSection[] = []; + if (spec.promptField && isRecord(input)) { + const prompt = input[spec.promptField]; + if (typeof prompt === "string" && prompt.length > 0) { + sections.push({ kind: "prose", title: "Prompt", text: prompt }); + } + } + + const inputFields = fieldsFromValue(input, spec.inputOrder, spec.promptField); + if (inputFields.length > 0) { + sections.push({ kind: "fields", title: "Details", fields: inputFields }); + } + + const outputFields = fieldsFromValue( + unwrapMcpResult(output), + spec.outputFields, + undefined, + spec.outputFields, + ); + if (outputFields.length > 0) { + sections.push({ kind: "fields", title: "Result", fields: outputFields }); + } + return sections; +} diff --git a/packages/protocol/src/tool-call-display.test.ts b/packages/protocol/src/tool-call-display.test.ts index 14548d1207..0dd49eba05 100644 --- a/packages/protocol/src/tool-call-display.test.ts +++ b/packages/protocol/src/tool-call-display.test.ts @@ -34,7 +34,7 @@ describe("shared tool-call display mapping", () => { }); expect(display).toEqual({ - displayName: "Exec Command", + displayName: "Exec command", }); }); @@ -81,7 +81,7 @@ describe("shared tool-call display mapping", () => { }); expect(display).toEqual({ - displayName: "Worktree Setup", + displayName: "Worktree setup", summary: "feature-branch", }); }); @@ -142,7 +142,7 @@ describe("shared tool-call display mapping", () => { error: null, detail: { type: "unknown", input: null, output: null }, }); - expect(display.displayName).toBe("Create Agent"); + expect(display.displayName).toBe("Create agent"); }); it("humanizes Paseo MCP tool names (Codex format)", () => { @@ -152,7 +152,7 @@ describe("shared tool-call display mapping", () => { error: null, detail: { type: "unknown", input: null, output: null }, }); - expect(display.displayName).toBe("Create Agent"); + expect(display.displayName).toBe("Create agent"); }); it("humanizes list_agents Paseo tool", () => { @@ -162,7 +162,7 @@ describe("shared tool-call display mapping", () => { error: null, detail: { type: "unknown", input: null, output: null }, }); - expect(display.displayName).toBe("List Agents"); + expect(display.displayName).toBe("List agents"); }); it("does not override speak tool display name", () => { diff --git a/packages/protocol/src/tool-call-display.ts b/packages/protocol/src/tool-call-display.ts index 952d64c129..6930fe187a 100644 --- a/packages/protocol/src/tool-call-display.ts +++ b/packages/protocol/src/tool-call-display.ts @@ -47,8 +47,9 @@ function humanizeToolName(name: string): string { .replace(/[._-]+/g, " ") .split(" ") .filter((segment) => segment.length > 0) - .map((segment) => `${segment[0]?.toUpperCase() ?? ""}${segment.slice(1)}`) - .join(" "); + .join(" ") + .toLowerCase() + .replace(/^./, (character) => character.toUpperCase()); } function formatErrorText(error: unknown): string | undefined { @@ -104,7 +105,7 @@ function buildCanonicalDetailDisplay(input: ToolCallDisplayInput): DetailDisplay }; case "worktree_setup": return { - displayName: "Worktree Setup", + displayName: "Worktree setup", summary: input.detail.branchName, }; case "sub_agent":