From 9521ee99552d1ecd6c9f103566874778cdf37ec5 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 18 Oct 2026 13:10:09 +0700 Subject: [PATCH] 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. --- docs/timeline-sync.md | 3 +- ...paseo-tool-call-details.codex.real.spec.ts | 2 +- packages/app/src/agent-stream/view.tsx | 3 +- packages/app/src/components/message.tsx | 9 +- .../app/src/components/tool-call-details.tsx | 2 +- .../app/src/tool-calls/presentation.test.ts | 77 ++-- packages/app/src/tool-calls/presentation.ts | 7 +- .../src/tool-calls/use-target-agent-title.ts | 19 + .../app/src/utils/tool-call-icon-name.test.ts | 10 + packages/app/src/utils/tool-call-icon-name.ts | 11 +- packages/app/src/utils/tool-call-icon.ts | 4 +- .../protocol/src/agent-message-display.ts | 25 -- .../paseo-tool-presentation/agent-message.ts | 31 ++ .../src/paseo-tool-presentation/catalog.ts | 349 ++++++++++++++++++ .../detail-sections.test.ts} | 2 +- .../detail-sections.ts} | 160 +------- .../src/paseo-tool-presentation/index.test.ts | 141 +++++++ .../src/paseo-tool-presentation/index.ts | 85 +++++ .../paseo-tool-presentation/summary-text.ts | 21 ++ packages/protocol/src/tool-call-display.ts | 31 +- .../src/server/agent/activity-curator.test.ts | 23 ++ .../src/server/agent/activity-curator.ts | 17 + .../src/server/agent/agent-sdk-types.ts | 3 + .../src/server/agent/mcp-server.test.ts | 35 ++ 24 files changed, 840 insertions(+), 230 deletions(-) create mode 100644 packages/app/src/tool-calls/use-target-agent-title.ts delete mode 100644 packages/protocol/src/agent-message-display.ts create mode 100644 packages/protocol/src/paseo-tool-presentation/agent-message.ts create mode 100644 packages/protocol/src/paseo-tool-presentation/catalog.ts rename packages/protocol/src/{paseo-tool-call-detail.test.ts => paseo-tool-presentation/detail-sections.test.ts} (98%) rename packages/protocol/src/{paseo-tool-call-detail.ts => paseo-tool-presentation/detail-sections.ts} (54%) create mode 100644 packages/protocol/src/paseo-tool-presentation/index.test.ts create mode 100644 packages/protocol/src/paseo-tool-presentation/index.ts create mode 100644 packages/protocol/src/paseo-tool-presentation/summary-text.ts 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();