mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
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:
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user