feat(app): label Paseo tool calls verb-first with their context (#7283)

Paseo tool rows put the variable part first ("QA messenger finished"), so
truncation hid the fixed part, and MCP calls showed only the humanized tool
name with no context. Every Paseo tool now has one catalog entry in
protocol/paseo-tool-presentation owning its fixed label, the input field
shown as context, and its detail layout. Agent-message events read
noun-first ("Agent finished" + sender) and use the Paseo icon; the app
ignored it before because the wire detail's legacy "bot" icon won.

Summaries are capped in code at 120 characters because prompts can be one
enormous line; the renderer is not trusted to clip them.

The activity curator behind `paseo logs` and get_agent_activity printed a
bare "[Agent message]" because the server tool-call type never declared the
agentMessage field the projection sets; it now prints the event, sender,
and body.
This commit is contained in:
Mohamed Boudra
2026-10-18 13:10:09 +07:00
committed by GitHub
parent bd5061e7bd
commit 9521ee9955
24 changed files with 840 additions and 230 deletions
+2 -1
View File
@@ -251,7 +251,8 @@ do not imply local ownership or authorization. Capture the sender title at deliv
removed senders must remain understandable without a live directory lookup.
The optional `agentMessage` payload carries event, sender, and delivered text. Shared client
presentation (`protocol/agent-message-display`) owns its heading and expanded details. The daemon
presentation (`protocol/paseo-tool-presentation`) owns its heading and expanded details, next to the
labels for every Paseo MCP tool. The daemon
must not construct UI labels or prepend sender labels to the semantic message body. The existing
`tool_call` / `plain_text` envelope is a compatibility adapter: older clients still parse the row and
expand its body. New clients use the typed payload, never infer the event from a tool name or parse
@@ -84,7 +84,7 @@ test.describe("real Codex Paseo tool-call presentation", () => {
expectedText: ["Prompt", GREETING, "Greeting child", "codex/gpt-5.4-mini", "Result"],
});
await captureToolCall(page, testInfo, {
label: "Send agent prompt",
label: "Send message",
artifact: "send-prompt-presentation",
expectedText: ["Prompt", GREETING, "Agent", "Result"],
});
+2 -1
View File
@@ -771,6 +771,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
status={data.status}
detail={data.detail}
cwd={context.cwd}
serverId={resolvedServerId}
metadata={data.metadata}
agentMessage={data.agentMessage}
isLastInSequence={isLastInSequence}
@@ -795,7 +796,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
/>
);
},
[context.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
[context.cwd, resolvedServerId, setInlineDetailsExpanded, handleToolCallOpenFile],
);
// Read through a stable event so live group updates do not change the renderer identity
+8 -1
View File
@@ -26,6 +26,7 @@ import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment";
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
import { useTargetAgentTitle } from "@/tool-calls/use-target-agent-title";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
import type { AgentCapabilityFlags, ToolCallDetail } from "@getpaseo/protocol/agent-types";
@@ -2208,6 +2209,7 @@ function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: Expa
interface ToolCallProps {
agentMessage?: AgentMessage;
serverId?: string;
toolName: string;
args?: unknown;
result?: unknown;
@@ -2228,6 +2230,7 @@ interface ToolCallProps {
export const ToolCall = memo(function ToolCall({
agentMessage,
serverId,
toolName,
args,
result,
@@ -2265,6 +2268,7 @@ export const ToolCall = memo(function ToolCall({
return undefined;
}, [detail, args, result]);
const targetAgentTitle = useTargetAgentTitle({ serverId, toolName, detail: effectiveDetail });
const presentation = useMemo(
() =>
buildToolCallPresentation({
@@ -2275,9 +2279,10 @@ export const ToolCall = memo(function ToolCall({
detail: effectiveDetail,
metadata,
cwd,
targetAgentTitle,
resolveIcon: resolveToolCallIcon,
}),
[toolName, status, error, effectiveDetail, metadata, cwd, agentMessage],
[toolName, status, error, effectiveDetail, metadata, cwd, agentMessage, targetAgentTitle],
);
const handleOpenFile = useMemo(() => {
const openFilePath = presentation.openFilePath;
@@ -2393,6 +2398,8 @@ export const ToolCall = memo(function ToolCall({
function areToolCallPropsEqual(previous: ToolCallProps, next: ToolCallProps) {
if (previous.toolName !== next.toolName) return false;
if (previous.serverId !== next.serverId) return false;
if (previous.agentMessage !== next.agentMessage) return false;
if (previous.args !== next.args) return false;
if (previous.result !== next.result) return false;
if (previous.error !== next.error) return false;
@@ -14,7 +14,7 @@ import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
import {
buildPaseoToolDetailSections,
type PaseoToolDetailSection,
} from "@getpaseo/protocol/paseo-tool-call-detail";
} from "@getpaseo/protocol/paseo-tool-presentation/index";
import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers";
import { highlightDiffLines } from "@/utils/diff-highlight";
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
@@ -1,9 +1,11 @@
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
import { describe, expect, it } from "vitest";
import { buildToolCallPresentation, type ToolCallPresentationIcon } from "./presentation";
const fakeIcons = {
paseo: (() => null) as ToolCallPresentationIcon,
brain: (() => null) as ToolCallPresentationIcon,
eye: (() => null) as ToolCallPresentationIcon,
wrench: (() => null) as ToolCallPresentationIcon,
@@ -12,7 +14,11 @@ const fakeIcons = {
function fakeResolveIcon(
toolName: string,
detail: ToolCallDetail | undefined,
agentMessage: AgentMessage | undefined,
): ToolCallPresentationIcon {
if (agentMessage) {
return fakeIcons.paseo;
}
if (detail?.type === "plan") {
return fakeIcons.brain;
}
@@ -94,32 +100,35 @@ describe("tool-call presentation", () => {
});
it.each([
["message", "Message from QA messenger"],
["finished", "QA messenger finished"],
["errored", "QA messenger errored"],
["permission-required", "QA messenger needs permission"],
["closed", "QA messenger closed"],
] as const)("presents %s from structured provenance without a second label", (event, heading) => {
const presentation = buildToolCallPresentation({
toolName: "internal_name_should_not_be_visible",
status: "completed",
error: null,
detail: { type: "plain_text", label: "legacy summary", text: "legacy body" },
agentMessage: {
event,
sender: { id: "server::sender", title: "QA messenger" },
text: "The delivered message.",
},
resolveIcon: fakeResolveIcon,
});
expect(presentation.displayName).toBe(heading);
expect(presentation.summary).toBeUndefined();
expect(presentation.detail).toEqual({
type: "plain_text",
icon: "bot",
text: "From agent: server::sender\n\nThe delivered message.",
});
});
["message", "Message received"],
["finished", "Agent finished"],
["errored", "Agent errored"],
["permission-required", "Agent needs permission"],
["closed", "Agent closed"],
] as const)(
"presents %s with a fixed label, the sender as context, and the Paseo icon",
(event, heading) => {
const presentation = buildToolCallPresentation({
toolName: "internal_name_should_not_be_visible",
status: "completed",
error: null,
detail: { type: "plain_text", label: "legacy summary", text: "legacy body", icon: "bot" },
agentMessage: {
event,
sender: { id: "server::sender", title: "QA messenger" },
text: "The delivered message.",
},
resolveIcon: fakeResolveIcon,
});
expect(presentation.displayName).toBe(heading);
expect(presentation.summary).toBe("QA messenger");
expect(presentation.icon).toBe(fakeIcons.paseo);
expect(presentation.detail).toEqual({
type: "plain_text",
text: "From agent: server::sender\n\nThe delivered message.",
});
},
);
it("uses an opaque sender ID when no title is available", () => {
const presentation = buildToolCallPresentation({
@@ -129,5 +138,19 @@ it("uses an opaque sender ID when no title is available", () => {
agentMessage: { event: "message", sender: { id: "remote::worker" }, text: "Hello" },
resolveIcon: fakeResolveIcon,
});
expect(presentation.displayName).toBe("Message from remote::worker");
expect(presentation.displayName).toBe("Message received");
expect(presentation.summary).toBe("remote::worker");
});
it("shows the target agent title as context for Paseo agent tools", () => {
const presentation = buildToolCallPresentation({
toolName: "mcp__paseo__kill_agent",
status: "completed",
error: null,
detail: { type: "unknown", input: { agentId: "agt_1" }, output: null },
targetAgentTitle: "Reviewer",
resolveIcon: fakeResolveIcon,
});
expect(presentation.displayName).toBe("Kill agent");
expect(presentation.summary).toBe("Reviewer");
});
+5 -2
View File
@@ -1,5 +1,5 @@
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
import { buildAgentMessageDisplay } from "@getpaseo/protocol/agent-message-display";
import { buildAgentMessageDisplay } from "@getpaseo/protocol/paseo-tool-presentation/index";
import type { PlanOutcome } from "@/components/plan-card";
import type { ComponentType } from "react";
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
@@ -22,6 +22,7 @@ interface BuildToolCallPresentationInput {
detail?: ToolCallDetail;
cwd?: string;
metadata?: Record<string, unknown>;
targetAgentTitle?: string;
resolveIcon: ToolCallIconResolver;
}
@@ -42,6 +43,7 @@ export interface ToolCallPresentation {
export type ToolCallIconResolver = (
toolName: string,
detail: ToolCallDetail | undefined,
agentMessage: AgentMessage | undefined,
) => ToolCallPresentationIcon;
function displayStatus(status: ToolCallStatus): ToolCallDisplayInput["status"] {
@@ -67,6 +69,7 @@ export function buildToolCallPresentation(
detail: detailForDisplay,
metadata: input.metadata,
cwd: input.cwd,
targetAgentTitle: input.targetAgentTitle,
});
const isLoadingDetails = isPendingToolCallDetail({
detail,
@@ -80,7 +83,7 @@ export function buildToolCallPresentation(
displayName: displayModel.displayName,
summary: displayModel.summary,
errorText: displayModel.errorText,
icon: input.resolveIcon(input.toolName, detail),
icon: input.resolveIcon(input.toolName, detail, input.agentMessage),
isLoadingDetails,
hasDetails,
canOpenDetails: hasDetails || isLoadingDetails,
@@ -0,0 +1,19 @@
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
import { getPaseoToolTargetAgentId } from "@getpaseo/protocol/paseo-tool-presentation/index";
import { useSessionStore } from "@/stores/session-store";
/** Title of the agent a Paseo tool call acts on, when this host knows it. */
export function useTargetAgentTitle(input: {
serverId: string | undefined;
toolName: string;
detail: ToolCallDetail | undefined;
}): string | undefined {
const agentId = getPaseoToolTargetAgentId(input.toolName, input.detail);
const { serverId } = input;
return useSessionStore((state) => {
if (!serverId || !agentId) return undefined;
const session = state.sessions[serverId];
const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId);
return agent?.title ?? undefined;
});
}
@@ -53,4 +53,14 @@ describe("resolveToolCallIconName", () => {
expect(icon).toBe("wrench");
});
it("uses the Paseo icon for agent messages despite the legacy bot detail icon", () => {
const icon = resolveToolCallIconName(
"agent_message",
{ type: "plain_text", text: "Done.", icon: "bot" },
{ event: "finished", sender: { id: "agt_1" }, text: "Done." },
);
expect(icon).toBe("paseo");
});
});
+10 -1
View File
@@ -1,3 +1,4 @@
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
import type { ToolCallDetail, ToolCallIconName } from "@getpaseo/protocol/agent-types";
import { isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization";
@@ -17,7 +18,15 @@ const TOOL_DETAIL_ICON_NAMES: Record<ToolCallDetail["type"], ToolCallIcon> = {
unknown: "wrench",
};
export function resolveToolCallIconName(toolName: string, detail?: ToolCallDetail): ToolCallIcon {
export function resolveToolCallIconName(
toolName: string,
detail?: ToolCallDetail,
agentMessage?: AgentMessage,
): ToolCallIcon {
// Agent messages are Paseo deliveries; the wire detail's legacy "bot" icon is for old clients.
if (agentMessage) {
return "paseo";
}
const lowerName = toolName.trim().toLowerCase();
if (detail?.type === "plain_text" && detail.icon) {
+3 -1
View File
@@ -10,6 +10,7 @@ import {
SquareTerminal,
Wrench,
} from "lucide-react-native";
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { resolveToolCallIconName, type ToolCallIcon } from "./tool-call-icon-name";
@@ -36,6 +37,7 @@ export function componentForToolCallIcon(name: ToolCallIcon): ToolCallIconCompon
export function resolveToolCallIcon(
toolName: string,
detail?: ToolCallDetail,
agentMessage?: AgentMessage,
): ToolCallIconComponent {
return componentForToolCallIcon(resolveToolCallIconName(toolName, detail));
return componentForToolCallIcon(resolveToolCallIconName(toolName, detail, agentMessage));
}
@@ -1,25 +0,0 @@
import type { AgentMessage } from "./agent-message.js";
import type { ToolCallDetail } from "./agent-types.js";
/** Presentation shared by clients; never used to encode provider prompts. */
export function buildAgentMessageDisplay(message: AgentMessage): {
displayName: string;
detail: ToolCallDetail;
} {
const sender = message.sender.title ?? message.sender.id;
const headings: Record<AgentMessage["event"], string> = {
message: `Message from ${sender}`,
finished: `${sender} finished`,
errored: `${sender} errored`,
"permission-required": `${sender} needs permission`,
closed: `${sender} closed`,
};
return {
displayName: headings[message.event],
detail: {
type: "plain_text",
icon: "bot",
text: `From agent: ${message.sender.id}\n\n${message.text}`,
},
};
}
@@ -0,0 +1,31 @@
import type { AgentMessage } from "../agent-message.js";
import type { ToolCallDetail } from "../agent-types.js";
import { toSummaryText } from "./summary-text.js";
export interface AgentMessageDisplay {
displayName: string;
summary?: string;
detail: ToolCallDetail;
}
/** Events read noun-first ("Agent finished"); the sender goes in the summary. */
const EVENT_LABELS: Record<AgentMessage["event"], string> = {
message: "Message received",
finished: "Agent finished",
errored: "Agent errored",
"permission-required": "Agent needs permission",
closed: "Agent closed",
};
/** Presentation shared by clients; never used to encode provider prompts. */
export function buildAgentMessageDisplay(message: AgentMessage): AgentMessageDisplay {
const summary = toSummaryText(message.sender.title ?? message.sender.id);
return {
displayName: EVENT_LABELS[message.event],
...(summary ? { summary } : {}),
detail: {
type: "plain_text",
text: `From agent: ${message.sender.id}\n\n${message.text}`,
},
};
}
@@ -0,0 +1,349 @@
/**
* One entry per Paseo MCP tool: the timeline label, the context shown after it,
* and how the expanded details lay out the call.
*
* Labels are fixed text so truncation never hides them. Actions read verb-first
* ("Send message"); the context goes in the summary.
*/
type ToolInput = Readonly<Record<string, unknown>>;
/** `targetAgent` is the target agent's title when known, else its ID. */
export type PaseoToolContext = (input: ToolInput, targetAgent: string | undefined) => unknown;
export interface PaseoToolDetailSpec {
promptField?: string;
inputOrder?: readonly string[];
outputFields?: readonly string[];
}
export interface PaseoToolEntry {
label: string;
context?: PaseoToolContext;
detail?: PaseoToolDetailSpec;
}
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",
"internal",
"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 AUTOMATION_OUTPUT = ["id", "status", "nextRunAt", "expiresAt"] as const;
function field(...keys: string[]): PaseoToolContext {
return (input) => keys.map((key) => input[key]).find(isPresent);
}
function targetAgent(): PaseoToolContext {
return (_input, agent) => agent;
}
function fieldOrTargetAgent(key: string): PaseoToolContext {
return (input, agent) => (isPresent(input[key]) ? input[key] : agent);
}
function workspaceContext(): PaseoToolContext {
return (input) =>
[input.title, input.branchName, input.branch, baseName(input.path)].find(isPresent);
}
function viewportContext(): PaseoToolContext {
return (input) =>
typeof input.width === "number" && typeof input.height === "number"
? `${input.width}×${input.height}`
: undefined;
}
function isPresent(value: unknown): boolean {
if (typeof value === "string") return value.trim().length > 0;
if (Array.isArray(value)) return value.length > 0;
return typeof value === "number";
}
function baseName(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
return value.split(/[\\/]/).findLast((segment) => segment.length > 0);
}
function browser(label: string, context?: PaseoToolContext): PaseoToolEntry {
return { label, ...(context ? { context } : {}), detail: { inputOrder: BROWSER_FIELDS } };
}
export const PASEO_TOOL_CATALOG = {
// Agents
create_agent: {
label: "Create agent",
context: field("title", "initialPrompt"),
detail: {
promptField: "initialPrompt",
inputOrder: AGENT_FIELDS,
outputFields: ["agentId", "status", "currentModeId", "cwd"],
},
},
send_agent_prompt: {
label: "Send message",
context: field("prompt"),
detail: {
promptField: "prompt",
inputOrder: AGENT_FIELDS,
outputFields: ["status", "lastMessage", "permission"],
},
},
get_agent_status: {
label: "Check agent",
context: targetAgent(),
detail: { inputOrder: ["agentId"] },
},
get_agent_activity: {
label: "Read agent activity",
context: targetAgent(),
detail: { inputOrder: ["agentId", "limit"] },
},
list_agents: {
label: "List agents",
detail: { inputOrder: ["cwd", "statuses", "sinceHours", "limit", "includeArchived"] },
},
update_agent: {
label: "Update agent",
context: fieldOrTargetAgent("name"),
detail: { inputOrder: AGENT_FIELDS },
},
set_agent_mode: {
label: "Set agent mode",
context: field("modeId"),
detail: { inputOrder: ["agentId", "modeId"] },
},
cancel_agent: {
label: "Cancel agent",
context: targetAgent(),
detail: { inputOrder: ["agentId"] },
},
archive_agent: {
label: "Archive agent",
context: targetAgent(),
detail: { inputOrder: ["agentId"] },
},
kill_agent: { label: "Kill agent", context: targetAgent(), detail: { inputOrder: ["agentId"] } },
list_pending_permissions: { label: "List permissions" },
respond_to_permission: {
label: "Respond to permission",
context: targetAgent(),
detail: { inputOrder: ["agentId", "requestId", "response"] },
},
// Workspaces and scripts
create_workspace: {
label: "Create workspace",
context: workspaceContext(),
detail: { inputOrder: WORKSPACE_FIELDS, outputFields: ["workspaceId", "projectId"] },
},
list_workspaces: { label: "List workspaces" },
rename_workspace: {
label: "Rename workspace",
context: field("title"),
detail: { inputOrder: ["title", "workspaceId"] },
},
archive_workspace: {
label: "Archive workspace",
context: field("workspaceId"),
detail: {
inputOrder: ["workspaceId"],
outputFields: ["workspaceId", "archivedAgentIds", "removedDirectory"],
},
},
list_workspace_scripts: { label: "List scripts", detail: { inputOrder: ["workspaceId"] } },
start_workspace_script: {
label: "Start script",
context: field("scriptName"),
detail: { inputOrder: ["workspaceId", "scriptName"] },
},
stop_workspace_script: {
label: "Stop script",
context: field("scriptName"),
detail: { inputOrder: ["workspaceId", "scriptName"] },
},
// Terminals
create_terminal: {
label: "Create terminal",
context: field("name", "command"),
detail: { inputOrder: ["cwd", "workspaceId", "name", "command"] },
},
list_terminals: { label: "List terminals", detail: { inputOrder: ["cwd", "all"] } },
capture_terminal: {
label: "Read terminal",
context: field("terminalId"),
detail: { inputOrder: ["terminalId", "lines"] },
},
send_terminal_keys: {
label: "Send keys",
context: field("keys"),
detail: { inputOrder: ["terminalId", "keys", "literal"] },
},
kill_terminal: {
label: "Kill terminal",
context: field("terminalId"),
detail: { inputOrder: ["terminalId"] },
},
// Schedules and heartbeats
create_schedule: {
label: "Create schedule",
context: field("name", "cron"),
detail: {
promptField: "prompt",
inputOrder: AUTOMATION_FIELDS,
outputFields: AUTOMATION_OUTPUT,
},
},
create_heartbeat: {
label: "Create heartbeat",
context: field("name", "cron"),
detail: {
promptField: "prompt",
inputOrder: AUTOMATION_FIELDS,
outputFields: AUTOMATION_OUTPUT,
},
},
update_schedule: {
label: "Update schedule",
context: field("name", "id"),
detail: { promptField: "prompt", inputOrder: AUTOMATION_FIELDS },
},
list_schedules: { label: "List schedules" },
inspect_schedule: {
label: "Inspect schedule",
context: field("id"),
detail: { inputOrder: ["id"] },
},
schedule_logs: {
label: "Read schedule logs",
context: field("id"),
detail: { inputOrder: ["id"] },
},
run_schedule_once: {
label: "Run schedule",
context: field("id"),
detail: { inputOrder: ["id"] },
},
pause_schedule: { label: "Pause schedule", context: field("id"), detail: { inputOrder: ["id"] } },
resume_schedule: {
label: "Resume schedule",
context: field("id"),
detail: { inputOrder: ["id"] },
},
delete_schedule: {
label: "Delete schedule",
context: field("id"),
detail: { inputOrder: ["id"] },
},
delete_heartbeat: {
label: "Delete heartbeat",
context: field("id"),
detail: { inputOrder: ["id"] },
},
// Providers
list_providers: { label: "List providers" },
list_models: {
label: "List models",
context: field("provider"),
detail: { inputOrder: ["provider"] },
},
list_profiles: { label: "List profiles" },
inspect_provider: {
label: "Inspect provider",
context: field("provider"),
detail: { inputOrder: ["provider", "cwd", "settings"] },
},
// Browser
browser_list_tabs: browser("List browser tabs"),
browser_new_tab: browser("Open browser tab", field("url")),
browser_close_tab: browser("Close browser tab", field("browserId")),
browser_navigate: browser("Navigate", field("url")),
browser_back: browser("Go back"),
browser_forward: browser("Go forward"),
browser_reload: browser("Reload page"),
browser_snapshot: browser("Snapshot page"),
browser_screenshot: browser("Take screenshot"),
browser_click: browser("Click element", field("ref")),
browser_hover: browser("Hover element", field("ref")),
browser_drag: browser("Drag element", field("sourceRef")),
browser_fill: browser("Fill field", field("ref")),
browser_select: browser("Select option", field("value", "ref")),
browser_type: browser("Type text", field("text")),
browser_keypress: browser("Press key", field("key")),
browser_upload: browser("Upload files", field("filePaths")),
browser_wait: browser("Wait for page", field("text", "url")),
browser_scroll: browser("Scroll page"),
browser_resize: browser("Resize browser", viewportContext()),
browser_logs: browser("Read browser logs"),
browser_evaluate: browser("Run JavaScript", field("function")),
} satisfies Record<string, PaseoToolEntry>;
export type PaseoToolName = keyof typeof PASEO_TOOL_CATALOG;
export function getPaseoToolEntry(leafName: string): PaseoToolEntry | undefined {
return Object.prototype.hasOwnProperty.call(PASEO_TOOL_CATALOG, leafName)
? PASEO_TOOL_CATALOG[leafName as PaseoToolName]
: undefined;
}
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { buildPaseoToolDetailSections } from "./paseo-tool-call-detail.js";
import { buildPaseoToolDetailSections } from "./index.js";
describe("Paseo tool-call detail presentation", () => {
it.each(["mcp__paseo__create_agent", "paseo.create_agent", "paseo_remote.create_agent"])(
@@ -1,4 +1,5 @@
import { getPaseoToolLeafName } from "./tool-name-normalization.js";
import { getPaseoToolLeafName } from "../tool-name-normalization.js";
import { getPaseoToolEntry, type PaseoToolDetailSpec } from "./catalog.js";
export interface PaseoToolDetailField {
label: string;
@@ -17,161 +18,6 @@ export type PaseoToolDetailSection =
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",
"internal",
"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",
@@ -330,7 +176,7 @@ export function buildPaseoToolDetailSections(
const leafName = getPaseoToolLeafName(toolName);
if (!leafName) return null;
const spec = TOOL_SPECS[leafName] ?? {};
const spec: PaseoToolDetailSpec = getPaseoToolEntry(leafName)?.detail ?? {};
const sections: PaseoToolDetailSection[] = [];
if (spec.promptField && isRecord(input)) {
const prompt = input[spec.promptField];
@@ -0,0 +1,141 @@
import { describe, expect, it } from "vitest";
import {
buildAgentMessageDisplay,
buildPaseoToolDisplay,
getPaseoToolTargetAgentId,
MAX_SUMMARY_CHARS,
PASEO_TOOL_NAMES,
} from "./index.js";
function display(toolName: string, input: unknown, targetAgentTitle?: string) {
return buildPaseoToolDisplay({
toolName,
detail: { type: "unknown", input, output: null },
...(targetAgentTitle ? { targetAgentTitle } : {}),
});
}
describe("Paseo tool display", () => {
it.each([
[
"send_agent_prompt",
{ agentId: "agt_1", prompt: "Fix the failing test" },
"Send message",
"Fix the failing test",
],
["create_agent", { title: "Reviewer", initialPrompt: "Review it" }, "Create agent", "Reviewer"],
["create_agent", { initialPrompt: "Review it" }, "Create agent", "Review it"],
["get_agent_status", { agentId: "agt_1" }, "Check agent", "agt_1"],
["update_agent", { agentId: "agt_1", name: "Renamed" }, "Update agent", "Renamed"],
["create_workspace", { path: "/tmp/repo/feature-x" }, "Create workspace", "feature-x"],
["create_workspace", { title: "Docs", branchName: "docs" }, "Create workspace", "Docs"],
["start_workspace_script", { scriptName: "web" }, "Start script", "web"],
["send_terminal_keys", { terminalId: "t1", keys: "npm test" }, "Send keys", "npm test"],
["create_schedule", { prompt: "Check CI", cron: "0 9 * * 1" }, "Create schedule", "0 9 * * 1"],
["browser_navigate", { url: "https://paseo.sh" }, "Navigate", "https://paseo.sh"],
[
"browser_upload",
{ ref: "@e1", filePaths: ["/a.png", "/b.png"] },
"Upload files",
"/a.png +1",
],
["browser_resize", { width: 800, height: 600 }, "Resize browser", "800×600"],
])("presents %s with a fixed label and its context", (tool, input, label, summary) => {
expect(display(`mcp__paseo__${tool}`, input)).toEqual({ displayName: label, summary });
});
it("omits the summary when a call has no context", () => {
expect(display("mcp__paseo__list_agents", {})).toEqual({ displayName: "List agents" });
});
it("recognizes every Paseo namespace format", () => {
for (const name of ["mcp__paseo__kill_agent", "paseo.kill_agent", "paseo_remote.kill_agent"]) {
expect(display(name, { agentId: "agt_1" })?.displayName).toBe("Kill agent");
}
});
it("returns null outside the Paseo namespace, including speak", () => {
expect(display("Bash", {})).toBeNull();
expect(display("mcp__paseo__speak", { text: "hi" })).toBeNull();
});
it("humanizes a Paseo tool that has no catalog entry", () => {
expect(display("mcp__paseo__brand_new_tool", {})).toEqual({ displayName: "Brand new tool" });
});
it("prefers the known agent title over the agent ID", () => {
expect(display("mcp__paseo__cancel_agent", { agentId: "agt_1" }, "Reviewer")).toEqual({
displayName: "Cancel agent",
summary: "Reviewer",
});
expect(
getPaseoToolTargetAgentId("mcp__paseo__cancel_agent", {
type: "unknown",
input: { agentId: "agt_1" },
output: null,
}),
).toBe("agt_1");
expect(
getPaseoToolTargetAgentId("Bash", { type: "unknown", input: { agentId: "x" }, output: null }),
).toBeNull();
});
it("truncates a massive single-line prompt and collapses whitespace", () => {
const prompt = `Line one\n\n line two ${"x".repeat(1_000_000)}`;
const summary = display("mcp__paseo__send_agent_prompt", { agentId: "a", prompt })?.summary;
expect(summary).toHaveLength(MAX_SUMMARY_CHARS);
expect(summary?.startsWith("Line one line two xxx")).toBe(true);
expect(summary?.endsWith("…")).toBe(true);
});
it("bounds the scan even when the prompt opens with a wall of whitespace", () => {
const prompt = `${" ".repeat(10_000)}hidden`;
const summary = display("mcp__paseo__send_agent_prompt", { agentId: "a", prompt })?.summary;
expect(summary).toBeUndefined();
});
it("keeps a short prompt intact", () => {
expect(
display("mcp__paseo__send_agent_prompt", { agentId: "a", prompt: " hi\nthere " })?.summary,
).toBe("hi there");
});
it("catalogs tool names as lowercase leaf names", () => {
for (const name of PASEO_TOOL_NAMES) {
expect(name).toMatch(/^[a-z_]+$/);
}
});
});
describe("agent message display", () => {
it.each([
["message", "Message received"],
["finished", "Agent finished"],
["errored", "Agent errored"],
["permission-required", "Agent needs permission"],
["closed", "Agent closed"],
] as const)("labels %s noun-first with the sender as context", (event, label) => {
const result = buildAgentMessageDisplay({
event,
sender: { id: "srv::agt_1", title: "QA messenger" },
text: "Done.",
});
expect(result.displayName).toBe(label);
expect(result.summary).toBe("QA messenger");
expect(result.detail).toEqual({ type: "plain_text", text: "From agent: srv::agt_1\n\nDone." });
});
it("falls back to the sender ID and truncates a long title", () => {
expect(
buildAgentMessageDisplay({ event: "finished", sender: { id: "srv::agt_1" }, text: "" })
.summary,
).toBe("srv::agt_1");
const summary = buildAgentMessageDisplay({
event: "finished",
sender: { id: "a", title: "t".repeat(500) },
text: "",
}).summary;
expect(summary).toHaveLength(MAX_SUMMARY_CHARS);
});
});
@@ -0,0 +1,85 @@
import type { ToolCallDetail } from "../agent-types.js";
import { getPaseoToolLeafName, isPaseoToolName } from "../tool-name-normalization.js";
import { getPaseoToolEntry, PASEO_TOOL_CATALOG, type PaseoToolName } from "./catalog.js";
import { toSummaryText } from "./summary-text.js";
export { buildAgentMessageDisplay, type AgentMessageDisplay } from "./agent-message.js";
export {
buildPaseoToolDetailSections,
type PaseoToolDetailField,
type PaseoToolDetailSection,
} from "./detail-sections.js";
export { MAX_SUMMARY_CHARS } from "./summary-text.js";
export type { PaseoToolName };
export interface PaseoToolDisplayInput {
toolName: string;
detail: ToolCallDetail;
/** Title of the agent named by the call's `agentId`, when the caller knows it. */
targetAgentTitle?: string;
}
export interface PaseoToolDisplay {
displayName: string;
summary?: string;
}
export const PASEO_TOOL_NAMES = Object.keys(PASEO_TOOL_CATALOG) as readonly PaseoToolName[];
/** Returns null for tools outside the Paseo namespace. */
export function buildPaseoToolDisplay(input: PaseoToolDisplayInput): PaseoToolDisplay | null {
const leafName = paseoLeafName(input.toolName);
if (!leafName) return null;
const entry = getPaseoToolEntry(leafName);
if (!entry) return { displayName: humanizeLeafName(leafName) };
const toolInput = readToolInput(input.detail);
const targetAgent = input.targetAgentTitle ?? readString(toolInput.agentId);
const summary = formatContext(entry.context?.(toolInput, targetAgent));
return { displayName: entry.label, ...(summary ? { summary } : {}) };
}
/** The agent a Paseo call acts on, so callers can resolve its title. */
export function getPaseoToolTargetAgentId(
toolName: string,
detail: ToolCallDetail | undefined,
): string | null {
if (!detail || !paseoLeafName(toolName)) return null;
return readString(readToolInput(detail).agentId) ?? null;
}
function paseoLeafName(toolName: string): string | null {
return isPaseoToolName(toolName) ? getPaseoToolLeafName(toolName) : null;
}
function readToolInput(detail: ToolCallDetail): Readonly<Record<string, unknown>> {
if (detail.type !== "unknown") return {};
const value = detail.input;
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function formatContext(value: unknown): string | undefined {
if (typeof value === "string") return toSummaryText(value);
if (typeof value === "number") return String(value);
if (Array.isArray(value)) {
const [first, ...rest] = value;
const head = typeof first === "string" ? toSummaryText(first) : undefined;
if (!head) return undefined;
return rest.length > 0 ? `${head} +${rest.length}` : head;
}
return undefined;
}
function humanizeLeafName(leafName: string): string {
const sentence = leafName
.replace(/[._-]+/g, " ")
.trim()
.toLowerCase();
return `${sentence[0]?.toUpperCase() ?? ""}${sentence.slice(1)}`;
}
@@ -0,0 +1,21 @@
/**
* Prompts and results can be one enormous line. Summaries are capped here so no
* consumer relies on its renderer to clip them.
*/
export const MAX_SUMMARY_CHARS = 120;
// Bounds the whitespace scan: inputs can be megabytes.
const SCAN_WINDOW_CHARS = MAX_SUMMARY_CHARS * 4;
export function toSummaryText(value: string): string | undefined {
const isPartial = value.length > SCAN_WINDOW_CHARS;
const collapsed = value.slice(0, SCAN_WINDOW_CHARS).replace(/\s+/g, " ").trim();
if (!collapsed) return undefined;
if (collapsed.length <= MAX_SUMMARY_CHARS && !isPartial) return collapsed;
const kept = dropTrailingHighSurrogate(collapsed.slice(0, MAX_SUMMARY_CHARS - 1)).trimEnd();
return `${kept}…`;
}
function dropTrailingHighSurrogate(text: string): string {
const last = text.charCodeAt(text.length - 1);
return last >= 0xd800 && last <= 0xdbff ? text.slice(0, -1) : text;
}
+20 -11
View File
@@ -1,6 +1,8 @@
import { buildAgentMessageDisplay } from "./agent-message-display.js";
import type { ToolCallTimelineItem } from "./agent-types.js";
import { getPaseoToolLeafName, isPaseoToolName } from "./tool-name-normalization.js";
import {
buildAgentMessageDisplay,
buildPaseoToolDisplay,
} from "./paseo-tool-presentation/index.js";
import { stripCwdPrefix } from "./path-utils.js";
export type ToolCallDisplayInput = Pick<
@@ -8,6 +10,8 @@ export type ToolCallDisplayInput = Pick<
"name" | "status" | "error" | "metadata" | "detail" | "agentMessage"
> & {
cwd?: string;
/** Title of the agent a Paseo call targets, when the caller knows it. */
targetAgentTitle?: string;
};
export interface ToolCallDisplayModel {
@@ -34,12 +38,6 @@ function humanizeToolName(name: string): string {
if (!trimmed) {
return name;
}
if (isPaseoToolName(trimmed)) {
const leaf = getPaseoToolLeafName(trimmed);
if (leaf) {
return humanizeToolName(leaf);
}
}
if (/[:./]/.test(trimmed) || trimmed.includes("__")) {
return trimmed;
}
@@ -152,8 +150,20 @@ function buildUnknownDetailOverride(input: ToolCallDisplayInput): DetailDisplay
}
export function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCallDisplayModel {
if (input.agentMessage) {
return { displayName: buildAgentMessageDisplay(input.agentMessage).displayName };
const errorText = input.status === "failed" ? formatErrorText(input.error) : undefined;
const paseoDisplay = input.agentMessage
? buildAgentMessageDisplay(input.agentMessage)
: buildPaseoToolDisplay({
toolName: input.name,
detail: input.detail,
targetAgentTitle: input.targetAgentTitle,
});
if (paseoDisplay) {
return {
displayName: paseoDisplay.displayName,
...(paseoDisplay.summary ? { summary: paseoDisplay.summary } : {}),
...(errorText ? { errorText } : {}),
};
}
const canonicalDisplay = buildCanonicalDetailDisplay(input);
const unknownDetailOverride = buildUnknownDetailOverride(input);
@@ -162,7 +172,6 @@ export function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCall
canonicalDisplay.displayName ??
humanizeToolName(input.name);
const summary = unknownDetailOverride.summary ?? canonicalDisplay.summary;
const errorText = input.status === "failed" ? formatErrorText(input.error) : undefined;
return {
displayName,
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { buildAgentForkContextAttachment, curateAgentActivity } from "./activity-curator.js";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import type { AgentTimelineRow } from "./agent-timeline-store-types.js";
import { formatAgentMessage, projectAgentMessage } from "./agent-messages/index.js";
function toolCallItem(params: {
callId: string;
@@ -78,6 +79,28 @@ describe("curateAgentActivity", () => {
);
});
it("renders delivered agent messages with their event, sender, and body", () => {
const delivered = (source: Parameters<typeof formatAgentMessage>[0]["source"], text: string) =>
projectAgentMessage({
type: "user_message",
text: formatAgentMessage({ id: `msg-${text.length}`, source, text }),
})!;
const timeline: AgentTimelineItem[] = [
delivered(
{ kind: "agent-message", agentId: "agt_lead", title: "CI lead" },
"Take ownership.\nRead the handoff.",
),
delivered(
{ kind: "agent-notification", agentId: "agt_child", event: "finished" },
"Child is done.",
),
];
expect(curateAgentActivity(timeline)).toBe(
"[Message received] CI lead\nTake ownership.\nRead the handoff.\n[Agent finished] agt_child\nChild is done.",
);
});
it("renders user/assistant/reasoning entries", () => {
const timeline: AgentTimelineItem[] = [
{ type: "user_message", text: "Hello" },
@@ -1,7 +1,9 @@
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { AgentTimelineRow } from "./agent-timeline-store-types.js";
import { isLikelyExternalToolName } from "@getpaseo/protocol/tool-name-normalization";
import { buildAgentMessageDisplay } from "@getpaseo/protocol/paseo-tool-presentation/index";
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
import { projectTimelineRows } from "./timeline-projection.js";
@@ -119,6 +121,17 @@ function formatToolCallEntry(
return activityEntry(summary ? `[${displayName}] ${summary}` : `[${displayName}]`);
}
function appendAgentMessageEntries(entries: ActivityEntry[], message: AgentMessage) {
const display = buildAgentMessageDisplay(message);
entries.push(
activityEntry(
display.summary ? `[${display.displayName}] ${display.summary}` : `[${display.displayName}]`,
),
);
const body = message.text.trim();
if (body) entries.push(activityEntry(body));
}
function curateProjectedActivityEntries(
items: readonly AgentTimelineItem[],
options?: ActivityCuratorOptions,
@@ -146,6 +159,10 @@ function curateProjectedActivityEntries(
appendTextEntry(entries, item, options);
break;
case "tool_call": {
if (item.agentMessage) {
appendAgentMessageEntries(entries, item.agentMessage);
break;
}
entries.push(formatToolCallEntry(item, options));
if (item.detail.type === "sub_agent" && item.detail.log.trim()) {
entries.push(activityEntry(item.detail.log.trim()));
@@ -8,6 +8,7 @@ import type {
ProviderOptions,
ToolPolicy,
} from "@getpaseo/protocol/agent-types";
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { PaseoToolCatalog } from "./tools/types.js";
@@ -342,6 +343,8 @@ interface ToolCallBase {
name: string;
detail: ToolCallDetail;
metadata?: Record<string, unknown>;
/** Set when the row is a delivered agent message rather than a provider tool call. */
agentMessage?: AgentMessage;
}
type ToolCallRunningTimelineItem = ToolCallBase & {
@@ -66,6 +66,7 @@ import type { ForgeService } from "../../services/forge-service.js";
import { areEquivalentPaths } from "../../utils/path.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { PASEO_TOOL_NAMES } from "@getpaseo/protocol/paseo-tool-presentation/index";
import { MutableDaemonConfigSchema, type AgentProfile } from "@getpaseo/protocol/messages";
import type { DaemonConfigStore } from "../daemon-config-store.js";
import type { BrowserToolsBroker, BrowserToolsExecuteInput } from "../browser-tools/broker.js";
@@ -888,6 +889,40 @@ describe("bundled Paseo skill", () => {
});
});
describe("Paseo tool timeline presentation", () => {
it("catalogs a timeline label for every advertised tool", async () => {
const server = await createAgentMcpServer({
agentManager: new BoundaryAgentManagerFake() as AgentManager,
agentStorage: new BoundaryAgentStorageFake() as AgentStorage,
providerSnapshotManager:
new BoundaryProviderSnapshotManagerFake() as unknown as ProviderSnapshotManager,
browserToolsEnabled: true,
browserToolsBroker: new FakeBrowserToolsBroker({
requestId: "req-catalog",
ok: true,
result: { command: "list_tabs", tabs: [] },
}) as BrowserToolsBroker,
callerAgentId: "agent-1",
logger: createTestLogger(),
});
const client = await connectInMemoryMcpClient(server);
try {
const { tools } = await client.listTools();
const cataloged = new Set<string>(PASEO_TOOL_NAMES);
// speak renders as a voice message, not a tool call row.
const uncataloged = tools
.map((tool) => tool.name)
.filter((name) => name !== "speak" && !cataloged.has(name));
expect(tools.length).toBeGreaterThan(40);
expect(uncataloged).toEqual([]);
} finally {
await client.close();
await server.close();
}
});
});
describe("browser MCP tools", () => {
const logger = createTestLogger();