mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 18:22:38 +00:00
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.
182 lines
4.9 KiB
TypeScript
182 lines
4.9 KiB
TypeScript
import type { ToolCallTimelineItem } from "./agent-types.js";
|
|
import {
|
|
buildAgentMessageDisplay,
|
|
buildPaseoToolDisplay,
|
|
} from "./paseo-tool-presentation/index.js";
|
|
import { stripCwdPrefix } from "./path-utils.js";
|
|
|
|
export type ToolCallDisplayInput = Pick<
|
|
ToolCallTimelineItem,
|
|
"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 {
|
|
displayName: string;
|
|
summary?: string;
|
|
errorText?: string;
|
|
}
|
|
|
|
interface DetailDisplay {
|
|
displayName?: string;
|
|
summary?: string;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function readString(value: unknown): string | undefined {
|
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
}
|
|
|
|
function humanizeToolName(name: string): string {
|
|
const trimmed = name.trim();
|
|
if (!trimmed) {
|
|
return name;
|
|
}
|
|
if (/[:./]/.test(trimmed) || trimmed.includes("__")) {
|
|
return trimmed;
|
|
}
|
|
|
|
return trimmed
|
|
.replace(/[._-]+/g, " ")
|
|
.split(" ")
|
|
.filter((segment) => segment.length > 0)
|
|
.join(" ")
|
|
.toLowerCase()
|
|
.replace(/^./, (character) => character.toUpperCase());
|
|
}
|
|
|
|
function formatErrorText(error: unknown): string | undefined {
|
|
if (error === null || error === undefined) {
|
|
return undefined;
|
|
}
|
|
if (typeof error === "string") {
|
|
return error;
|
|
}
|
|
if (isRecord(error) && typeof error.content === "string") {
|
|
return error.content;
|
|
}
|
|
try {
|
|
return JSON.stringify(error, null, 2);
|
|
} catch {
|
|
return String(error);
|
|
}
|
|
}
|
|
|
|
function buildFilePathDisplay(
|
|
displayName: string,
|
|
filePath: string,
|
|
cwd: string | undefined,
|
|
): DetailDisplay {
|
|
return {
|
|
displayName,
|
|
summary: stripCwdPrefix(filePath, cwd),
|
|
};
|
|
}
|
|
|
|
function buildCanonicalDetailDisplay(input: ToolCallDisplayInput): DetailDisplay {
|
|
switch (input.detail.type) {
|
|
case "shell":
|
|
return {
|
|
displayName: "Shell",
|
|
summary: input.detail.command,
|
|
};
|
|
case "read":
|
|
return buildFilePathDisplay("Read", input.detail.filePath, input.cwd);
|
|
case "edit":
|
|
return buildFilePathDisplay("Edit", input.detail.filePath, input.cwd);
|
|
case "write":
|
|
return buildFilePathDisplay("Write", input.detail.filePath, input.cwd);
|
|
case "search":
|
|
return {
|
|
displayName: "Search",
|
|
summary: input.detail.query,
|
|
};
|
|
case "fetch":
|
|
return {
|
|
displayName: "Fetch",
|
|
summary: input.detail.url,
|
|
};
|
|
case "worktree_setup":
|
|
return {
|
|
displayName: "Worktree setup",
|
|
summary: input.detail.branchName,
|
|
};
|
|
case "sub_agent":
|
|
return {
|
|
displayName: readString(input.detail.subAgentType) ?? "Task",
|
|
summary: readString(input.detail.description),
|
|
};
|
|
case "plain_text":
|
|
return {
|
|
summary: input.detail.label,
|
|
};
|
|
case "plan":
|
|
return {
|
|
displayName: "Plan",
|
|
};
|
|
case "unknown":
|
|
return {};
|
|
default:
|
|
throw new Error("unreachable");
|
|
}
|
|
}
|
|
|
|
function buildUnknownDetailOverride(input: ToolCallDisplayInput): DetailDisplay {
|
|
const lowerName = input.name.trim().toLowerCase();
|
|
if (input.detail.type === "unknown" && lowerName === "task") {
|
|
return {
|
|
displayName: "Task",
|
|
summary: isRecord(input.metadata) ? readString(input.metadata.subAgentActivity) : undefined,
|
|
};
|
|
}
|
|
if (input.detail.type === "unknown" && lowerName === "thinking") {
|
|
return {
|
|
displayName: "Thinking",
|
|
};
|
|
}
|
|
if (lowerName === "terminal") {
|
|
return {
|
|
displayName: "Terminal",
|
|
summary: input.detail.type === "plain_text" ? readString(input.detail.label) : undefined,
|
|
};
|
|
}
|
|
return {};
|
|
}
|
|
|
|
export function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCallDisplayModel {
|
|
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);
|
|
const displayName =
|
|
unknownDetailOverride.displayName ??
|
|
canonicalDisplay.displayName ??
|
|
humanizeToolName(input.name);
|
|
const summary = unknownDetailOverride.summary ?? canonicalDisplay.summary;
|
|
|
|
return {
|
|
displayName,
|
|
...(summary ? { summary } : {}),
|
|
...(errorText ? { errorText } : {}),
|
|
};
|
|
}
|