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