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();