diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md
index d1a770f219..118ad628ce 100644
--- a/docs/timeline-sync.md
+++ b/docs/timeline-sync.md
@@ -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
diff --git a/packages/app/e2e/browser/paseo-tool-call-details.codex.real.spec.ts b/packages/app/e2e/browser/paseo-tool-call-details.codex.real.spec.ts
index 87afd783a4..597a1ebd54 100644
--- a/packages/app/e2e/browser/paseo-tool-call-details.codex.real.spec.ts
+++ b/packages/app/e2e/browser/paseo-tool-call-details.codex.real.spec.ts
@@ -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"],
});
diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx
index c9b44ed679..c1466b3682 100644
--- a/packages/app/src/agent-stream/view.tsx
+++ b/packages/app/src/agent-stream/view.tsx
@@ -771,6 +771,7 @@ const AgentStreamViewComponent = forwardRef
);
},
- [context.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
+ [context.cwd, resolvedServerId, setInlineDetailsExpanded, handleToolCallOpenFile],
);
// Read through a stable event so live group updates do not change the renderer identity
diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx
index 314f414d48..d5a2244505 100644
--- a/packages/app/src/components/message.tsx
+++ b/packages/app/src/components/message.tsx
@@ -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;
diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx
index 50e4052118..d67eb15f26 100644
--- a/packages/app/src/components/tool-call-details.tsx
+++ b/packages/app/src/components/tool-call-details.tsx
@@ -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";
diff --git a/packages/app/src/tool-calls/presentation.test.ts b/packages/app/src/tool-calls/presentation.test.ts
index fde0ba020f..fc609ee8f6 100644
--- a/packages/app/src/tool-calls/presentation.test.ts
+++ b/packages/app/src/tool-calls/presentation.test.ts
@@ -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");
});
diff --git a/packages/app/src/tool-calls/presentation.ts b/packages/app/src/tool-calls/presentation.ts
index f25d752624..b0ffbb4d5d 100644
--- a/packages/app/src/tool-calls/presentation.ts
+++ b/packages/app/src/tool-calls/presentation.ts
@@ -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;
+ 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,
diff --git a/packages/app/src/tool-calls/use-target-agent-title.ts b/packages/app/src/tool-calls/use-target-agent-title.ts
new file mode 100644
index 0000000000..877478d396
--- /dev/null
+++ b/packages/app/src/tool-calls/use-target-agent-title.ts
@@ -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;
+ });
+}
diff --git a/packages/app/src/utils/tool-call-icon-name.test.ts b/packages/app/src/utils/tool-call-icon-name.test.ts
index 14da899c39..272422ecbd 100644
--- a/packages/app/src/utils/tool-call-icon-name.test.ts
+++ b/packages/app/src/utils/tool-call-icon-name.test.ts
@@ -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");
+ });
});
diff --git a/packages/app/src/utils/tool-call-icon-name.ts b/packages/app/src/utils/tool-call-icon-name.ts
index 28bfedb700..f5fb12559d 100644
--- a/packages/app/src/utils/tool-call-icon-name.ts
+++ b/packages/app/src/utils/tool-call-icon-name.ts
@@ -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 = {
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) {
diff --git a/packages/app/src/utils/tool-call-icon.ts b/packages/app/src/utils/tool-call-icon.ts
index 804b68c4ca..4e1d83e040 100644
--- a/packages/app/src/utils/tool-call-icon.ts
+++ b/packages/app/src/utils/tool-call-icon.ts
@@ -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));
}
diff --git a/packages/protocol/src/agent-message-display.ts b/packages/protocol/src/agent-message-display.ts
deleted file mode 100644
index c95f9e8206..0000000000
--- a/packages/protocol/src/agent-message-display.ts
+++ /dev/null
@@ -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 = {
- 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}`,
- },
- };
-}
diff --git a/packages/protocol/src/paseo-tool-presentation/agent-message.ts b/packages/protocol/src/paseo-tool-presentation/agent-message.ts
new file mode 100644
index 0000000000..33eb44163b
--- /dev/null
+++ b/packages/protocol/src/paseo-tool-presentation/agent-message.ts
@@ -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 = {
+ 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}`,
+ },
+ };
+}
diff --git a/packages/protocol/src/paseo-tool-presentation/catalog.ts b/packages/protocol/src/paseo-tool-presentation/catalog.ts
new file mode 100644
index 0000000000..1706134523
--- /dev/null
+++ b/packages/protocol/src/paseo-tool-presentation/catalog.ts
@@ -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>;
+
+/** `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;
+
+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;
+}
diff --git a/packages/protocol/src/paseo-tool-call-detail.test.ts b/packages/protocol/src/paseo-tool-presentation/detail-sections.test.ts
similarity index 98%
rename from packages/protocol/src/paseo-tool-call-detail.test.ts
rename to packages/protocol/src/paseo-tool-presentation/detail-sections.test.ts
index 25a637e45c..ab3c123e17 100644
--- a/packages/protocol/src/paseo-tool-call-detail.test.ts
+++ b/packages/protocol/src/paseo-tool-presentation/detail-sections.test.ts
@@ -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"])(
diff --git a/packages/protocol/src/paseo-tool-call-detail.ts b/packages/protocol/src/paseo-tool-presentation/detail-sections.ts
similarity index 54%
rename from packages/protocol/src/paseo-tool-call-detail.ts
rename to packages/protocol/src/paseo-tool-presentation/detail-sections.ts
index 4b06d49923..e2fbcbe9b0 100644
--- a/packages/protocol/src/paseo-tool-call-detail.ts
+++ b/packages/protocol/src/paseo-tool-presentation/detail-sections.ts
@@ -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> = {
- create_workspace: {
- inputOrder: WORKSPACE_FIELDS,
- outputFields: ["workspaceId", "projectId"],
- },
- list_workspaces: {},
- archive_workspace: {
- inputOrder: ["workspaceId"],
- outputFields: ["workspaceId", "archivedAgentIds", "removedDirectory"],
- },
- rename_workspace: { inputOrder: ["title", "workspaceId"] },
- create_agent: {
- promptField: "initialPrompt",
- inputOrder: AGENT_FIELDS,
- outputFields: ["agentId", "status", "currentModeId", "cwd"],
- },
- send_agent_prompt: {
- promptField: "prompt",
- inputOrder: AGENT_FIELDS,
- outputFields: ["status", "lastMessage", "permission"],
- },
- get_agent_status: { inputOrder: ["agentId"] },
- list_agents: { inputOrder: ["cwd", "statuses", "sinceHours", "limit", "includeArchived"] },
- cancel_agent: { inputOrder: ["agentId"] },
- archive_agent: { inputOrder: ["agentId"] },
- kill_agent: { inputOrder: ["agentId"] },
- update_agent: { inputOrder: AGENT_FIELDS },
- get_agent_activity: { inputOrder: ["agentId", "limit"] },
- set_agent_mode: { inputOrder: ["agentId", "modeId"] },
- list_workspace_scripts: { inputOrder: ["workspaceId"] },
- start_workspace_script: { inputOrder: ["workspaceId", "scriptName"] },
- stop_workspace_script: { inputOrder: ["workspaceId", "scriptName"] },
- list_terminals: { inputOrder: ["cwd", "all"] },
- create_terminal: { inputOrder: ["cwd", "workspaceId", "name", "command"] },
- kill_terminal: { inputOrder: ["terminalId"] },
- capture_terminal: { inputOrder: ["terminalId", "lines"] },
- send_terminal_keys: { inputOrder: ["terminalId", "keys", "literal"] },
- create_schedule: {
- promptField: "prompt",
- inputOrder: AUTOMATION_FIELDS,
- outputFields: ["id", "status", "nextRunAt", "expiresAt"],
- },
- create_heartbeat: {
- promptField: "prompt",
- inputOrder: AUTOMATION_FIELDS,
- outputFields: ["id", "status", "nextRunAt", "expiresAt"],
- },
- delete_heartbeat: { inputOrder: ["id"] },
- list_schedules: {},
- inspect_schedule: { inputOrder: ["id"] },
- pause_schedule: { inputOrder: ["id"] },
- resume_schedule: { inputOrder: ["id"] },
- delete_schedule: { inputOrder: ["id"] },
- update_schedule: { promptField: "prompt", inputOrder: AUTOMATION_FIELDS },
- schedule_logs: { inputOrder: ["id"] },
- run_schedule_once: { inputOrder: ["id"] },
- list_providers: {},
- list_models: { inputOrder: ["provider"] },
- list_profiles: {},
- inspect_provider: { inputOrder: ["provider", "cwd", "settings"] },
- list_pending_permissions: {},
- respond_to_permission: { inputOrder: ["agentId", "requestId", "response"] },
- browser_list_tabs: {},
- browser_new_tab: { inputOrder: BROWSER_FIELDS },
- browser_snapshot: { inputOrder: BROWSER_FIELDS },
- browser_click: { inputOrder: BROWSER_FIELDS },
- browser_fill: { inputOrder: BROWSER_FIELDS },
- browser_wait: { inputOrder: BROWSER_FIELDS },
- browser_type: { inputOrder: BROWSER_FIELDS },
- browser_keypress: { inputOrder: BROWSER_FIELDS },
- browser_navigate: { inputOrder: BROWSER_FIELDS },
- browser_back: { inputOrder: BROWSER_FIELDS },
- browser_forward: { inputOrder: BROWSER_FIELDS },
- browser_reload: { inputOrder: BROWSER_FIELDS },
- browser_screenshot: { inputOrder: BROWSER_FIELDS },
- browser_upload: { inputOrder: BROWSER_FIELDS },
- browser_hover: { inputOrder: BROWSER_FIELDS },
- browser_select: { inputOrder: BROWSER_FIELDS },
- browser_drag: { inputOrder: BROWSER_FIELDS },
- browser_logs: { inputOrder: BROWSER_FIELDS },
- browser_evaluate: { inputOrder: BROWSER_FIELDS },
- browser_scroll: { inputOrder: BROWSER_FIELDS },
- browser_resize: { inputOrder: BROWSER_FIELDS },
- browser_close_tab: { inputOrder: BROWSER_FIELDS },
-};
-
const FIELD_LABELS: Readonly> = {
agentId: "Agent",
archivedAgentIds: "Archived agents",
@@ -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];
diff --git a/packages/protocol/src/paseo-tool-presentation/index.test.ts b/packages/protocol/src/paseo-tool-presentation/index.test.ts
new file mode 100644
index 0000000000..bea9acd400
--- /dev/null
+++ b/packages/protocol/src/paseo-tool-presentation/index.test.ts
@@ -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);
+ });
+});
diff --git a/packages/protocol/src/paseo-tool-presentation/index.ts b/packages/protocol/src/paseo-tool-presentation/index.ts
new file mode 100644
index 0000000000..c04606a231
--- /dev/null
+++ b/packages/protocol/src/paseo-tool-presentation/index.ts
@@ -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> {
+ if (detail.type !== "unknown") return {};
+ const value = detail.input;
+ return typeof value === "object" && value !== null && !Array.isArray(value)
+ ? (value as Record)
+ : {};
+}
+
+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)}`;
+}
diff --git a/packages/protocol/src/paseo-tool-presentation/summary-text.ts b/packages/protocol/src/paseo-tool-presentation/summary-text.ts
new file mode 100644
index 0000000000..57e7405771
--- /dev/null
+++ b/packages/protocol/src/paseo-tool-presentation/summary-text.ts
@@ -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;
+}
diff --git a/packages/protocol/src/tool-call-display.ts b/packages/protocol/src/tool-call-display.ts
index ab597b5acd..db0aa6db20 100644
--- a/packages/protocol/src/tool-call-display.ts
+++ b/packages/protocol/src/tool-call-display.ts
@@ -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,
diff --git a/packages/server/src/server/agent/activity-curator.test.ts b/packages/server/src/server/agent/activity-curator.test.ts
index 58a8a8b4f1..77814f1779 100644
--- a/packages/server/src/server/agent/activity-curator.test.ts
+++ b/packages/server/src/server/agent/activity-curator.test.ts
@@ -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[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" },
diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts
index efd0a8e48d..ec5bceb1cb 100644
--- a/packages/server/src/server/agent/activity-curator.ts
+++ b/packages/server/src/server/agent/activity-curator.ts
@@ -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()));
diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts
index 34d36c9e16..59fb6022e9 100644
--- a/packages/server/src/server/agent/agent-sdk-types.ts
+++ b/packages/server/src/server/agent/agent-sdk-types.ts
@@ -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;
+ /** Set when the row is a delivered agent message rather than a provider tool call. */
+ agentMessage?: AgentMessage;
}
type ToolCallRunningTimelineItem = ToolCallBase & {
diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts
index 9246f463d0..cf2e6ab217 100644
--- a/packages/server/src/server/agent/mcp-server.test.ts
+++ b/packages/server/src/server/agent/mcp-server.test.ts
@@ -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(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();