Make Paseo tool calls readable (#4066)

Present Paseo-owned prompts, inputs, and results as readable provider-neutral sections with sentence-case labels, backed by focused tests and real-provider UI verification.
This commit is contained in:
Mohamed Boudra
2026-08-30 11:02:29 +02:00
committed by GitHub
parent 6688c92bc6
commit ce335de1cd
13 changed files with 778 additions and 19 deletions
@@ -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<Locator> {
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<string | RegExp> },
): Promise<void> {
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<void> {
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 });
}
});
});
+9 -2
View File
@@ -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();
+30 -1
View File
@@ -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<E2EWorker> {
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<void> {
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`,
);
}
+4
View File
@@ -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 (
<ToolCallDetailsContent
toolName={toolName}
detail={effectiveDetail}
errorText={presentation.errorText}
maxHeight={maxDetailHeight}
@@ -3207,6 +3210,7 @@ export const ToolCall = memo(function ToolCall({
);
}, [
shouldRenderInline,
toolName,
effectiveDetail,
presentation.errorText,
presentation.isLoadingDetails,
@@ -11,6 +11,10 @@ import { StyleSheet } from "react-native-unistyles";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
import {
buildPaseoToolDetailSections,
type PaseoToolDetailSection,
} from "@getpaseo/protocol/paseo-tool-call-detail";
import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers";
import { highlightDiffLines } from "@/utils/diff-highlight";
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
@@ -27,6 +31,7 @@ const ScrollView = isWeb ? RNScrollView : GHScrollView;
// ---- Content Component ----
interface ToolCallDetailsContentProps {
toolName?: string;
detail?: ToolCallDetail;
errorText?: string;
maxHeight?: number;
@@ -626,7 +631,42 @@ function buildUnknownSections(detail: UnknownDetail, ds: DetailStyles, t: TFunct
return out;
}
function PaseoDetailSection({ section }: { section: PaseoToolDetailSection }) {
return (
<View style={styles.paseoSection}>
<Text style={styles.paseoSectionTitle}>{section.title}</Text>
{section.kind === "prose" ? (
<Text selectable style={styles.paseoProse}>
{section.text}
</Text>
) : (
<View style={styles.paseoFields}>
{section.fields.map((field) => (
<View key={field.label} style={styles.paseoFieldRow}>
<Text style={styles.paseoFieldLabel}>{field.label}</Text>
<Text selectable style={styles.paseoFieldValue}>
{field.value}
</Text>
</View>
))}
</View>
)}
</View>
);
}
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) => <PaseoDetailSection key={section.title} section={section} />);
}
function buildDetailSections(
toolName: string | undefined,
detail: ToolCallDetail | undefined,
diffLines: DiffLine[] | undefined,
ds: DetailStyles,
@@ -701,7 +741,7 @@ function buildDetailSections(
return [<ScrollablePlainTextSection key="plain-text" text={detail.text} ds={ds} />];
}
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<ViewSty
}
export function ToolCallDetailsContent({
toolName,
detail,
errorText,
maxHeight,
@@ -752,7 +793,7 @@ export function ToolCallDetailsContent({
const ds = useDetailStyles(detail, resolvedMaxHeight, fillAvailableHeight);
const diffLines = useDiffLines(detail);
const sections: ReactNode[] = buildDetailSections(detail, diffLines, ds, t);
const sections: ReactNode[] = buildDetailSections(toolName, detail, diffLines, ds, t);
if (errorText) {
sections.push(<ErrorSection key="error" errorText={errorText} ds={ds} />);
@@ -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],
},
@@ -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 (
<View style={styles.container}>
@@ -181,6 +189,7 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
{/* Content */}
<BottomSheetScrollView style={styles.content} contentContainerStyle={styles.contentContainer}>
<ToolCallDetailsContent
toolName={toolName}
detail={detail}
errorText={errorText}
fillAvailableHeight
@@ -66,7 +66,7 @@ describe("tool-call presentation", () => {
});
expect(presentation).toMatchObject({
displayName: "Exec Command",
displayName: "Exec command",
icon: fakeIcons.wrench,
isLoadingDetails: true,
hasDetails: false,
+1 -1
View File
@@ -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", () => {
@@ -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",
});
});
@@ -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();
});
});
@@ -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<Record<string, ToolDetailSpec>> = {
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<Record<string, string>> = {
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<string, unknown> {
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<string, unknown>,
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;
}
@@ -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", () => {
+4 -3
View File
@@ -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":