mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* feat(web-shell): show tool arguments and result in expanded cards Cards outside the built-in detail renderers expanded to the same 80-character result summary shown while collapsed, so the chevron promised details it never delivered. Render the call's arguments and result instead, pretty-printing a JSON object or array and falling back to plain text, with the trajectory inspector's 4000/40 000 caps and a Show more action. Fixes #15280 * fix(web-shell): address tool card details review findings * test(web-shell): cover tool card review findings * fix(web-shell): preserve tool card JSON payload fidelity * fix(web-shell): sanitize tool payloads and cancelled results * fix(web-shell): bound tool payloads by render mode * fix(web-shell): memoize tool card payload formatting * fix(web-shell): size tool card JSON by render cap and scope its rules * fix(web-templates): raise document export budget for tool cards * fix(web-shell): address round 3 tool card review suggestions * fix(web-shell): gate Show more on escaped payload length * fix(web-shell): stop rendering the cancelled input dump as a result A cancelled call that recorded no output carries the daemon's redacted input dump in rawOutput.text, written there as the same string as reason. cancelledOutputText accepted it as output, so the expanded card showed the call's own arguments twice, once as Result. Skip a text candidate that equals reason while keeping output/stdout. * test(web-shell): pin the arguments escape guard and the inline cap Both behaviours were unpinned: replacing the arguments-side sanitized-length comparison with the raw length, or lowering MAX_TOOL_PAYLOAD_LENGTH to 400, left the suites green. JSON.stringify escapes C0 bytes but not the bidi controls, so an object argument carrying U+202E is the reachable shape where escaping moves the payload past the cap. --------- Co-authored-by: Shaojin Wen <[email protected]>
938 lines
30 KiB
TypeScript
938 lines
30 KiB
TypeScript
import type { ACPToolCall } from '../../adapters/types.js';
|
||
import { isAskUserQuestionToolName } from '../../constants/toolNames.js';
|
||
|
||
export { isAskUserQuestionToolName } from '../../constants/toolNames.js';
|
||
|
||
export { isActiveToolStatus } from '../../adapters/toolClassification.js';
|
||
|
||
/**
|
||
* Internal-tool-name → display-name lookup. This is a standalone copy of
|
||
* core's `ToolDisplayNames` (mapped to wire names, as the CLI's shared
|
||
* `tool-display-map.ts` does): the web-shell is a browser bundle and
|
||
* intentionally does not depend on `@qwen-code/qwen-code-core`, so the map
|
||
* can't be imported. Keep the canonical tool entries in sync with core's
|
||
* `ToolDisplayNames`; the extra lowercase ACP aliases below (bash, read,
|
||
* write, …) are web-shell-only conveniences with no core equivalent.
|
||
*/
|
||
export const TOOL_DISPLAY_NAMES: Record<string, string> = {
|
||
exec: 'Exec',
|
||
edit: 'Edit',
|
||
write_file: 'WriteFile',
|
||
read_file: 'ReadFile',
|
||
zoom_image: 'ZoomImage',
|
||
grep: 'Grep',
|
||
grep_search: 'Grep',
|
||
glob: 'Glob',
|
||
run_shell_command: 'Shell',
|
||
todo_write: 'TodoList',
|
||
get_goal: 'Goal',
|
||
update_goal: 'UpdateGoal',
|
||
propose_goal: 'ProposeGoal',
|
||
save_memory: 'SaveMemory',
|
||
manage_memory: 'ManageMemory',
|
||
search_memory: 'SearchMemory',
|
||
agent: 'Agent',
|
||
advisor: 'Advisor',
|
||
skill: 'Skill',
|
||
exit_plan_mode: 'ExitPlanMode',
|
||
web_fetch: 'WebFetch',
|
||
webfetch: 'WebFetch',
|
||
fetch: 'WebFetch',
|
||
list_directory: 'ListFiles',
|
||
lsp: 'Lsp',
|
||
ask_user_question: 'AskUserQuestion',
|
||
cron_create: 'CronCreate',
|
||
cron_list: 'CronList',
|
||
cron_delete: 'CronDelete',
|
||
autofix_pr: 'AutofixPr',
|
||
loop_wakeup: 'LoopWakeup',
|
||
create_sub_session: 'CreateSubSession',
|
||
task_stop: 'TaskStop',
|
||
list_agents: 'ListAgents',
|
||
send_message: 'SendMessage',
|
||
structured_output: 'StructuredOutput',
|
||
monitor: 'Monitor',
|
||
notebook_edit: 'NotebookEdit',
|
||
tool_search: 'ToolSearch',
|
||
tool_call: 'ToolCall',
|
||
read_mcp_resource: 'ReadMcpResource',
|
||
enter_worktree: 'EnterWorktree',
|
||
exit_worktree: 'ExitWorktree',
|
||
enter_plan_mode: 'EnterPlanMode',
|
||
task_create: 'TaskCreate',
|
||
task_update: 'TaskUpdate',
|
||
task_list: 'TaskList',
|
||
team_create: 'TeamCreate',
|
||
team_delete: 'TeamDelete',
|
||
team_plan_approval: 'TeamPlanApproval',
|
||
request_shutdown: 'RequestShutdown',
|
||
workflow: 'Workflow',
|
||
artifact: 'Artifact',
|
||
record_artifact: 'RecordArtifact',
|
||
record_source: 'RecordSource',
|
||
report_findings: 'ReportFindings',
|
||
web_search: 'WebSearch',
|
||
image_gen: 'ImageGen',
|
||
omni_downsample_image: 'DownsampleImage',
|
||
omni_downscale_video: 'DownscaleVideo',
|
||
omni_downsample_audio: 'DownsampleAudio',
|
||
omni_extract_keyframes: 'ExtractKeyframes',
|
||
omni_extract_audio: 'ExtractAudio',
|
||
omni_clip_video: 'ClipVideo',
|
||
omni_convert_image: 'ConvertImage',
|
||
omni_transcribe_audio: 'TranscribeAudio',
|
||
omni_clip_image: 'ClipImage',
|
||
omni_clip_audio: 'ClipAudio',
|
||
omni_caption_image: 'CaptionImage',
|
||
omni_caption_audio: 'CaptionAudio',
|
||
omni_ocr_image: 'OcrImage',
|
||
omni_understand_video_segments: 'UnderstandVideoSegments',
|
||
omni_recall_media_memory: 'RecallMediaMemory',
|
||
display_image: 'DisplayImage',
|
||
bash: 'Shell',
|
||
shell: 'Shell Command',
|
||
read: 'ReadFile',
|
||
write: 'WriteFile',
|
||
search: 'Grep',
|
||
};
|
||
|
||
/**
|
||
* Escape bare C0/C1 control characters (BEL, BS, CR, DEL, ESC, the 8-bit
|
||
* CSI, …) to inert, visible text, mirroring the CLI's
|
||
* `sanitizeMultilineForDisplay`. React escapes HTML but not control bytes,
|
||
* so LLM-generated tool descriptions carrying a stray `\r`/BEL/ESC would
|
||
* otherwise garble the rendered panel. `\n`/`\t` are excluded — callers
|
||
* collapse whitespace before rendering single-line labels.
|
||
*/
|
||
// Matches bare C0/C1 control bytes but not `\n`/`\t` (mirrors the CLI's
|
||
// MULTILINE_CONTROL_CHARS_REGEX), plus the Unicode bidi embedding/isolate
|
||
// controls (U+202A–202E, U+2066–2069) so a crafted filename can't visually
|
||
// reorder or spoof its extension (bidi/"trojan source" style attacks).
|
||
/* eslint-disable no-control-regex */
|
||
const CONTROL_CHARS_REGEX =
|
||
/[\x00-\x08\x0b-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g;
|
||
/* eslint-enable no-control-regex */
|
||
|
||
export function sanitizeControlChars(text: string): string {
|
||
return text.replace(CONTROL_CHARS_REGEX, (ch) => {
|
||
switch (ch) {
|
||
case '\b':
|
||
return '\\b';
|
||
case '\f':
|
||
return '\\f';
|
||
case '\r':
|
||
return '\\r';
|
||
default:
|
||
return `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`;
|
||
}
|
||
});
|
||
}
|
||
|
||
export function formatToolDisplayName(toolName: string): string {
|
||
if (!toolName.trim()) return 'Tool';
|
||
const exact = TOOL_DISPLAY_NAMES[toolName];
|
||
if (exact) return exact;
|
||
const lower = toolName.toLowerCase();
|
||
if (lower === 'web_fetch' || lower === 'webfetch' || lower === 'fetch') {
|
||
return 'WebFetch';
|
||
}
|
||
return toolName;
|
||
}
|
||
|
||
/**
|
||
* Locale-aware tool display name for chat-stream badges. Looks up the
|
||
* `toolName.<wire_name>` i18n key; when the active language has no entry the
|
||
* translator returns the key verbatim, in which case we fall back to the
|
||
* English {@link formatToolDisplayName}. Pass the `t` from `useI18n()`.
|
||
*/
|
||
export function localizeToolDisplayName(
|
||
toolName: string,
|
||
t: (key: string, vars?: Record<string, string | number>) => string,
|
||
): string {
|
||
const displayName = formatToolDisplayName(toolName);
|
||
const keys = [
|
||
`toolName.${toolName}`,
|
||
`toolName.${toolName.toLowerCase()}`,
|
||
`toolName.${displayName.toLowerCase()}`,
|
||
];
|
||
for (const key of keys) {
|
||
const translated = t(key);
|
||
if (translated !== key) return translated;
|
||
}
|
||
return displayName;
|
||
}
|
||
|
||
export function isAdvisorToolName(toolName: string): boolean {
|
||
return toolName.toLowerCase() === 'advisor';
|
||
}
|
||
|
||
export function isCompletedAskUserQuestion(tool: ACPToolCall): boolean {
|
||
return (
|
||
tool.status === 'completed' && isAskUserQuestionToolName(tool.toolName)
|
||
);
|
||
}
|
||
|
||
export function getQuestionAnswerResult(tool: ACPToolCall): {
|
||
text: string;
|
||
answers: Array<{ question: string; answer: string }>;
|
||
} | null {
|
||
const output = tool.rawOutput;
|
||
if (
|
||
!output ||
|
||
typeof output !== 'object' ||
|
||
!('type' in output) ||
|
||
output.type !== 'ask_user_question_answers' ||
|
||
!('text' in output) ||
|
||
typeof output.text !== 'string' ||
|
||
!('answers' in output) ||
|
||
!Array.isArray(output.answers) ||
|
||
!output.answers.every(
|
||
(entry: unknown): entry is { question: string; answer: string } =>
|
||
!!entry &&
|
||
typeof entry === 'object' &&
|
||
'question' in entry &&
|
||
typeof entry.question === 'string' &&
|
||
'answer' in entry &&
|
||
typeof entry.answer === 'string',
|
||
)
|
||
) {
|
||
return null;
|
||
}
|
||
return { text: output.text, answers: output.answers };
|
||
}
|
||
|
||
export function truncateText(text: string, max: number): string {
|
||
if (text.length <= max) return text;
|
||
return text.slice(0, max) + '...';
|
||
}
|
||
|
||
/** Longest payload stretch a transcript card renders before "Show more". */
|
||
export const MAX_TOOL_PAYLOAD_LENGTH = 4000;
|
||
|
||
/** Ceiling after "Show more", matching the trajectory inspector. */
|
||
export const EXPANDED_TOOL_PAYLOAD_LENGTH = 40_000;
|
||
|
||
export function stringifyValue(value: unknown): string {
|
||
if (value === undefined || value === null) return '';
|
||
const text = typeof value === 'string' ? value : safeJson(value);
|
||
return text.trim();
|
||
}
|
||
|
||
function safeJson(value: unknown): string {
|
||
try {
|
||
return JSON.stringify(value, null, 2) ?? '';
|
||
} catch {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Re-indents a canonical JSON object or array for display. The input must be
|
||
* exactly its compact JSON form or its two-space form; anything else — plain
|
||
* text, a primitive, a truncated document, JSON Lines, a mixed JSON/text log
|
||
* or non-canonical JSON — is returned as recorded so the caller can fall back
|
||
* to plain text. Formatting therefore never rewrites literals, duplicate
|
||
* keys, key order or string escapes.
|
||
*
|
||
* These rules are scoped to the transcript tool card. The sibling
|
||
* ToolCallDetail used by the turn-calls / trajectory panes still fences via a
|
||
* bare JSON.parse, so identical bytes can render differently per surface;
|
||
* lifting one shared Arguments/Result renderer is tracked as a follow-up
|
||
* rather than widened into this change.
|
||
*/
|
||
export function formatJsonPayload(
|
||
text: string,
|
||
maxFormattedLength = EXPANDED_TOOL_PAYLOAD_LENGTH,
|
||
maxInputLength = EXPANDED_TOOL_PAYLOAD_LENGTH,
|
||
): {
|
||
text: string;
|
||
json: boolean;
|
||
} {
|
||
// Pretty-printing can expand a compact payload well past the render cap, and
|
||
// parsing a multi-megabyte string is not worth it for output the card will
|
||
// never show in full.
|
||
if (text.length > maxInputLength) {
|
||
return { text, json: false };
|
||
}
|
||
const trimmed = text.trim();
|
||
if (!trimmed) return { text, json: false };
|
||
|
||
const document = formatJsonDocument(trimmed, maxFormattedLength);
|
||
if (document !== undefined) return { text: document, json: true };
|
||
|
||
return { text, json: false };
|
||
}
|
||
|
||
const MAX_JSON_FORMAT_DEPTH = 32;
|
||
|
||
function formatJsonDocument(
|
||
text: string,
|
||
maxFormattedLength: number,
|
||
): string | undefined {
|
||
if (!/^[[{]/.test(text)) return undefined;
|
||
let value: unknown;
|
||
try {
|
||
value = JSON.parse(text);
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
if (value === null || typeof value !== 'object') return undefined;
|
||
if (exceedsJsonDepth(value, MAX_JSON_FORMAT_DEPTH)) return undefined;
|
||
|
||
// Compare against both canonical forms; only an exact match is safe to
|
||
// reformat, because anything else would rewrite recorded literals or keys.
|
||
const pretty = JSON.stringify(value, null, 2);
|
||
if (text === pretty) return text;
|
||
|
||
const compact = JSON.stringify(value);
|
||
if (text !== compact || pretty.length > maxFormattedLength) return undefined;
|
||
return pretty;
|
||
}
|
||
|
||
function exceedsJsonDepth(root: unknown, maxDepth: number): boolean {
|
||
const stack: Array<[unknown, number]> = [[root, 1]];
|
||
while (stack.length > 0) {
|
||
const current = stack.pop();
|
||
if (!current) break;
|
||
const [value, depth] = current;
|
||
if (depth > maxDepth) return true;
|
||
if (Array.isArray(value)) {
|
||
for (const entry of value) stack.push([entry, depth + 1]);
|
||
} else if (value !== null && typeof value === 'object') {
|
||
for (const entry of Object.values(value)) {
|
||
stack.push([entry, depth + 1]);
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// The tool-header description is shown single-line (CSS-ellipsised) when the
|
||
// row is collapsed and fully wrapped when it is expanded, so we keep the whole
|
||
// string rather than hard-capping it at a line's worth of characters. A
|
||
// generous ceiling still guards against a pathological multi-megabyte command
|
||
// bloating the DOM.
|
||
const MAX_DESCRIPTION_LENGTH = 2000;
|
||
|
||
const GENERATED_UI_TOOL_NAME = 'mcp__generated-ui__generated_ui_show';
|
||
|
||
export function getToolDescription(
|
||
tool: ACPToolCall,
|
||
workspaceCwd?: string,
|
||
): string {
|
||
if (isSkillToolName(tool.toolName)) {
|
||
const skillName = getStringArg(tool.args, 'skill');
|
||
if (skillName) return truncateText(skillName, MAX_DESCRIPTION_LENGTH);
|
||
}
|
||
if (tool.toolName === GENERATED_UI_TOOL_NAME) {
|
||
// The card title says what the view shows; the tool title only repeats
|
||
// the protocol name.
|
||
const title = getStringArg(tool.args, 'title');
|
||
if (title) return truncateText(title, MAX_DESCRIPTION_LENGTH);
|
||
}
|
||
const fromTitle = getDescriptionFromTitle(tool, workspaceCwd);
|
||
if (fromTitle) return truncateText(fromTitle, MAX_DESCRIPTION_LENGTH);
|
||
const fromArgs = getDescriptionFromArgs(tool, workspaceCwd);
|
||
if (fromArgs) return truncateText(fromArgs, MAX_DESCRIPTION_LENGTH);
|
||
return '';
|
||
}
|
||
|
||
export function getToolSummaryDescription(
|
||
tool: ACPToolCall,
|
||
workspaceCwd?: string,
|
||
): string {
|
||
if (!isShellToolName(tool.toolName)) {
|
||
return getToolDescription(tool, workspaceCwd);
|
||
}
|
||
|
||
const description = getStringArg(tool.args, 'description');
|
||
if (description) return truncateText(description, MAX_DESCRIPTION_LENGTH);
|
||
|
||
const fromArgs = getDescriptionFromArgs(tool, workspaceCwd, {
|
||
includeTimeout: false,
|
||
});
|
||
if (fromArgs) return truncateText(fromArgs, MAX_DESCRIPTION_LENGTH);
|
||
return '';
|
||
}
|
||
|
||
export function getShellToolSemanticDescription(tool: ACPToolCall): string {
|
||
if (!isShellToolName(tool.toolName)) return '';
|
||
return getStringArg(tool.args, 'description');
|
||
}
|
||
|
||
export function extractText(tool: ACPToolCall): string | null {
|
||
if (!tool.content) {
|
||
return extractRawOutputText(tool.rawOutput);
|
||
}
|
||
for (const b of tool.content) {
|
||
if (b.type === 'content' && b.content?.text) return b.content.text;
|
||
}
|
||
return extractRawOutputText(tool.rawOutput);
|
||
}
|
||
|
||
export function hasRecordedOutput(tool: ACPToolCall): boolean {
|
||
return (
|
||
tool.rawOutput != null ||
|
||
(tool.content?.some(
|
||
(block) =>
|
||
block.type === 'content' && typeof block.content?.text === 'string',
|
||
) ??
|
||
false)
|
||
);
|
||
}
|
||
|
||
export function getAdvisorDisplayText(tool: ACPToolCall): string | null {
|
||
return extractRawOutputText(tool.rawOutput) ?? extractText(tool);
|
||
}
|
||
|
||
export function getToolResultSummary(tool: ACPToolCall): string {
|
||
if (tool.status !== 'completed' && tool.status !== 'failed') return '';
|
||
|
||
const name = tool.toolName.toLowerCase();
|
||
if (name === 'grep_search' || name === 'grep' || name === 'search') {
|
||
const rawSummary = parseGrepSummary(
|
||
(extractRawOutputText(tool.rawOutput) ?? '').trim(),
|
||
);
|
||
if (rawSummary) return rawSummary;
|
||
}
|
||
|
||
if (isAdvisorToolName(name)) {
|
||
const raw = getAdvisorReview(tool.rawOutput);
|
||
if (raw) return truncateText(raw.verdict.trim().replace(/\s+/g, ' '), 80);
|
||
const fallback = getAdvisorDisplayText(tool);
|
||
return fallback ? truncateText(fallback.split('\n')[0] ?? '', 80) : '';
|
||
}
|
||
|
||
const text = extractText(tool);
|
||
if (!text) return '';
|
||
|
||
const lines = text.split('\n');
|
||
const lineCount = lines.length;
|
||
|
||
if (name === 'read' || name === 'read_file' || name === 'readfile') {
|
||
return `${lineCount} line(s)`;
|
||
}
|
||
|
||
if (name === 'glob') {
|
||
const itemCount = lines.filter((l) => l.trim()).length;
|
||
return `Found ${itemCount} matching file(s)`;
|
||
}
|
||
|
||
if (name === 'list_directory' || name === 'listfiles') {
|
||
const itemCount = lines.filter((l) => l.trim()).length;
|
||
return `${itemCount} item(s)`;
|
||
}
|
||
|
||
if (isShellToolName(name)) {
|
||
if (lineCount > 3) return `${lineCount} lines of output`;
|
||
const firstLine = lines[0] || '';
|
||
return truncateText(firstLine, 80);
|
||
}
|
||
|
||
if (name === 'grep_search' || name === 'grep' || name === 'search') {
|
||
const summary = parseGrepSummary(text.trim());
|
||
if (summary) return summary;
|
||
|
||
const matchCount = lines.filter((l) => l.trim()).length;
|
||
return `${matchCount} result(s)`;
|
||
}
|
||
|
||
if (
|
||
name === 'edit' ||
|
||
name === 'write' ||
|
||
name === 'write_file' ||
|
||
name === 'editfile'
|
||
) {
|
||
return '';
|
||
}
|
||
|
||
if (name === 'webfetch' || name === 'web_fetch' || name === 'fetch') {
|
||
const firstLine = lines[0] || '';
|
||
return truncateText(firstLine, 80);
|
||
}
|
||
|
||
if (name === 'websearch' || name === 'web_search') {
|
||
const matchCount = lines.filter((l) => l.trim()).length;
|
||
if (matchCount > 1) return `${matchCount} result(s)`;
|
||
return lines[0] || '';
|
||
}
|
||
|
||
if (isAskUserQuestionToolName(name)) return '';
|
||
|
||
const firstLine = lines[0] || '';
|
||
return truncateText(firstLine, 80);
|
||
}
|
||
|
||
export function getEmptyMcpToolTitleDescription(
|
||
toolName: string | undefined,
|
||
title: string | undefined,
|
||
input: Record<string, unknown> | undefined,
|
||
): string | undefined {
|
||
if (
|
||
!toolName?.startsWith('mcp__') ||
|
||
!input ||
|
||
Object.keys(input).length > 0
|
||
) {
|
||
return undefined;
|
||
}
|
||
// core's mcp-tool.ts getDescription serializes arguments as JSON; the CLI
|
||
// tool-call-emitter may prefix the MCP display name. Require confirmed-empty
|
||
// input so missing or nonempty arguments keep their original title.
|
||
const trimmed = title?.trim() ?? '';
|
||
if (trimmed === '{}') return '';
|
||
const match = /^(.+) \((.+) MCP Server\): \{\}$/.exec(trimmed);
|
||
// Preserve prose or aliased names when the prefix cannot be confirmed.
|
||
if (match === null) return undefined;
|
||
const rawName = `mcp__${match[2]}__${match[1]}`;
|
||
if (
|
||
toolName !== rawName &&
|
||
toolName !== normalizeToolNameForProvider(rawName)
|
||
)
|
||
return undefined;
|
||
return `${match[1]} (${match[2]} MCP Server)`;
|
||
}
|
||
|
||
export function isEmptyMcpToolTitle(
|
||
toolName: string | undefined,
|
||
title: string | undefined,
|
||
input: Record<string, unknown> | undefined,
|
||
): boolean {
|
||
return getEmptyMcpToolTitleDescription(toolName, title, input) !== undefined;
|
||
}
|
||
|
||
// The web-shell bundle cannot import core, so keep this provider-name mirror
|
||
// aligned with packages/core/src/utils/tool-name-utils.ts.
|
||
const MAX_TOOL_NAME_LENGTH = 63;
|
||
const PROVIDER_SAFE_TOOL_NAME = /^[A-Za-z][A-Za-z0-9_-]*$/;
|
||
|
||
function normalizeToolNameForProvider(name: string): string {
|
||
if (
|
||
name.length <= MAX_TOOL_NAME_LENGTH &&
|
||
PROVIDER_SAFE_TOOL_NAME.test(name)
|
||
) {
|
||
return name;
|
||
}
|
||
|
||
const sanitized = name.replace(/[^A-Za-z0-9_-]/g, '_');
|
||
const normalized = /^[A-Za-z]/.test(sanitized)
|
||
? sanitized
|
||
: `tool_${sanitized}`;
|
||
const suffix = `_${stableToolNameHash(name)}`;
|
||
return `${normalized.slice(0, MAX_TOOL_NAME_LENGTH - suffix.length)}${suffix}`;
|
||
}
|
||
|
||
function stableToolNameHash(name: string): string {
|
||
let hash = 2166136261;
|
||
for (let index = 0; index < name.length; index += 1) {
|
||
hash = Math.imul(hash ^ name.charCodeAt(index), 16777619);
|
||
}
|
||
return (hash >>> 0).toString(36).padStart(7, '0');
|
||
}
|
||
|
||
function getDescriptionFromTitle(
|
||
tool: ACPToolCall,
|
||
workspaceCwd?: string,
|
||
): string | null {
|
||
if (!tool.title) return null;
|
||
|
||
const displayName = formatToolDisplayName(tool.toolName);
|
||
const title = tool.title.trim();
|
||
const emptyMcpDescription = getEmptyMcpToolTitleDescription(
|
||
tool.toolName,
|
||
title,
|
||
tool.args,
|
||
);
|
||
if (emptyMcpDescription !== undefined) {
|
||
return emptyMcpDescription
|
||
? formatDescriptionPaths(emptyMcpDescription, workspaceCwd)
|
||
: null;
|
||
}
|
||
if (title === tool.toolName || title === displayName) return null;
|
||
|
||
const prefixes = [displayName, tool.toolName];
|
||
for (const prefix of prefixes) {
|
||
if (title.startsWith(prefix)) {
|
||
const suffix = title.slice(prefix.length);
|
||
if (/^(:\s*|\s+)/.test(suffix)) {
|
||
return formatDescriptionPaths(
|
||
suffix.replace(/^:\s*|\s+/, ''),
|
||
workspaceCwd,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
return formatDescriptionPaths(title, workspaceCwd);
|
||
}
|
||
|
||
function parseGrepSummary(text: string): string | null {
|
||
if (text === 'No matches found') return text;
|
||
if (/^Found \d+ match(?:es)?(?: \(truncated\))?$/.test(text)) return text;
|
||
return null;
|
||
}
|
||
|
||
function getDescriptionFromArgs(
|
||
tool: ACPToolCall,
|
||
workspaceCwd?: string,
|
||
options: { includeTimeout?: boolean } = {},
|
||
): string {
|
||
const args = tool.args || {};
|
||
const name = tool.toolName.toLowerCase();
|
||
const includeTimeout = options.includeTimeout ?? true;
|
||
|
||
if (args.command) {
|
||
let description = String(args.command);
|
||
if (args.directory && name !== 'shell') {
|
||
description += ` [in ${pathForDisplay(String(args.directory), workspaceCwd)}]`;
|
||
}
|
||
if (args.is_background) {
|
||
description += ' [background]';
|
||
} else if (includeTimeout && args.timeout) {
|
||
description += ` [timeout: ${String(args.timeout)}ms]`;
|
||
}
|
||
const argDescription = getStringArg(args, 'description');
|
||
if (argDescription) {
|
||
description += ` (${argDescription})`;
|
||
}
|
||
return truncateText(description, MAX_DESCRIPTION_LENGTH);
|
||
}
|
||
if (name === 'grep_search' || name === 'grep' || name === 'search') {
|
||
const pattern = args.pattern ?? args.query;
|
||
if (pattern) {
|
||
let description = `'${String(pattern)}'`;
|
||
if (args.path) {
|
||
description += ` in path '${String(args.path)}'`;
|
||
} else if (name === 'grep_search' || name === 'grep') {
|
||
description += ` in path './'`;
|
||
}
|
||
if (args.glob) description += ` (filter: '${String(args.glob)}')`;
|
||
return description;
|
||
}
|
||
}
|
||
if (name === 'glob' && args.pattern) {
|
||
let description = `'${String(args.pattern)}'`;
|
||
if (args.path) {
|
||
description += ` in path '${String(args.path)}'`;
|
||
}
|
||
return description;
|
||
}
|
||
if (args.file_path) {
|
||
const description = getStringArg(args, 'description');
|
||
if (description) return description;
|
||
return pathForDisplay(String(args.file_path), workspaceCwd);
|
||
}
|
||
if (args.url) {
|
||
const url = String(args.url);
|
||
const prompt =
|
||
typeof args.prompt === 'string' ? (args.prompt as string) : undefined;
|
||
const desc = prompt ? `${url} — "${truncateText(prompt, 40)}"` : url;
|
||
return truncateText(desc, 80);
|
||
}
|
||
if (args.path) return pathForDisplay(String(args.path), workspaceCwd);
|
||
if (args.query) {
|
||
return truncateText(String(args.query), 60);
|
||
}
|
||
if (name === 'list_directory' || name === 'listfiles') {
|
||
const candidate = args.path || args.directory || '';
|
||
return pathForDisplay(String(candidate), workspaceCwd);
|
||
}
|
||
return getStringArg(args, 'description');
|
||
}
|
||
|
||
function getStringArg(
|
||
args: Record<string, unknown> | undefined,
|
||
key: string,
|
||
): string {
|
||
const value = args?.[key];
|
||
return typeof value === 'string' ? value.trim().replace(/\n/g, ' ') : '';
|
||
}
|
||
|
||
/**
|
||
* Like every other is*ToolName helper, normalizes case so callers can pass
|
||
* the raw wire name. One predicate on purpose: ToolGroup gates the detail
|
||
* view, the ToolLine route and the collapsed keep-mounted behaviour on
|
||
* this, and three hand-inlined copies could diverge on a rename with no
|
||
* compile error — routing would then disagree with mounting.
|
||
*/
|
||
export function isWorkflowToolName(name: string): boolean {
|
||
return name.toLowerCase() === 'workflow';
|
||
}
|
||
|
||
export function isShellToolName(name: string): boolean {
|
||
const normalized = name.toLowerCase();
|
||
return (
|
||
normalized === 'run_shell_command' ||
|
||
normalized === 'bash' ||
|
||
normalized === 'shell' ||
|
||
normalized === 'execute_command'
|
||
);
|
||
}
|
||
|
||
export function isSkillToolName(name: string): boolean {
|
||
return name.toLowerCase() === 'skill';
|
||
}
|
||
|
||
export function toolContainsCallId(
|
||
tool: ACPToolCall,
|
||
toolCallId: string,
|
||
): boolean {
|
||
if (tool.callId === toolCallId) return true;
|
||
return (
|
||
tool.subTools?.some((sub) => toolContainsCallId(sub, toolCallId)) ?? false
|
||
);
|
||
}
|
||
|
||
function formatDescriptionPaths(
|
||
description: string,
|
||
workspaceCwd?: string,
|
||
): string {
|
||
const trimmed = description.trim();
|
||
if (isAbsoluteLikePath(normalizeSeparators(trimmed))) {
|
||
return pathForDisplay(trimmed, workspaceCwd);
|
||
}
|
||
|
||
return trimmed.replace(
|
||
/(^|[\s'"(])((?:[A-Za-z]:)?\/[^\s'")]+)/g,
|
||
(_match, prefix: string, filePath: string) =>
|
||
prefix + pathForDisplay(filePath, workspaceCwd),
|
||
);
|
||
}
|
||
|
||
function pathForDisplay(filePath: string, workspaceCwd?: string): string {
|
||
const normalizedPath = normalizeSeparators(filePath);
|
||
const normalizedCwd = workspaceCwd
|
||
? normalizeSeparators(workspaceCwd).replace(/\/+$/, '')
|
||
: '';
|
||
|
||
if (
|
||
normalizedCwd &&
|
||
(normalizedPath === normalizedCwd ||
|
||
normalizedPath.startsWith(`${normalizedCwd}/`))
|
||
) {
|
||
const relativePath = normalizedPath.slice(normalizedCwd.length + 1);
|
||
return relativePath || '.';
|
||
}
|
||
|
||
if (isAbsoluteLikePath(normalizedPath)) {
|
||
return basename(normalizedPath);
|
||
}
|
||
|
||
return normalizedPath;
|
||
}
|
||
|
||
function normalizeSeparators(filePath: string): string {
|
||
return filePath.replace(/\\/g, '/');
|
||
}
|
||
|
||
function isAbsoluteLikePath(filePath: string): boolean {
|
||
return filePath.startsWith('/') || /^[A-Za-z]:\//.test(filePath);
|
||
}
|
||
|
||
function basename(filePath: string): string {
|
||
const trimmed = filePath.replace(/\/+$/, '');
|
||
return trimmed.split('/').pop() || filePath;
|
||
}
|
||
|
||
// ── Shared agent helpers (used by ParallelAgentsGroup & SubAgentPanel) ──
|
||
|
||
export function getTaskExecutionRecord(
|
||
rawOutput: unknown,
|
||
): Record<string, unknown> | undefined {
|
||
if (!rawOutput || typeof rawOutput !== 'object') return undefined;
|
||
const record = rawOutput as Record<string, unknown>;
|
||
return record['type'] === 'task_execution' ? record : undefined;
|
||
}
|
||
|
||
export function getAgentCancellationReason(agent: ACPToolCall): string {
|
||
if (!agent.rawOutput || typeof agent.rawOutput !== 'object') return '';
|
||
const raw = agent.rawOutput as Record<string, unknown>;
|
||
const terminateReason =
|
||
typeof raw.terminateReason === 'string' ? raw.terminateReason : '';
|
||
return (
|
||
(typeof raw.reason === 'string' && raw.reason) ||
|
||
(terminateReason && terminateReason !== 'GOAL' && terminateReason) ||
|
||
(typeof raw.error === 'string' && raw.error) ||
|
||
''
|
||
);
|
||
}
|
||
|
||
export function isAgentCancelled(agent: ACPToolCall): boolean {
|
||
if (!agent.rawOutput || typeof agent.rawOutput !== 'object') return false;
|
||
const raw = agent.rawOutput as Record<string, unknown>;
|
||
const status = typeof raw.status === 'string' ? raw.status.toLowerCase() : '';
|
||
const reason = getAgentCancellationReason(agent);
|
||
return (
|
||
status === 'cancelled' ||
|
||
status === 'canceled' ||
|
||
reason.toLowerCase().includes('cancel')
|
||
);
|
||
}
|
||
|
||
export function getSubagentDetailsUnavailableReason(
|
||
agent: ACPToolCall,
|
||
): string | undefined {
|
||
if (agent.subagentSessionReady !== false) return undefined;
|
||
const rawStatus =
|
||
agent.rawOutput && typeof agent.rawOutput === 'object'
|
||
? (agent.rawOutput as Record<string, unknown>)['status']
|
||
: undefined;
|
||
// Safe projections can map cancellation to failed while retaining this flag.
|
||
if (
|
||
agent.wasCancelled ||
|
||
(typeof rawStatus === 'string' &&
|
||
['cancelled', 'canceled'].includes(rawStatus.toLowerCase()))
|
||
)
|
||
return 'subagent.cancelled';
|
||
if (
|
||
agent.status === 'failed' ||
|
||
getTaskExecutionRecord(agent.rawOutput)?.['status'] === 'failed'
|
||
)
|
||
return 'subagent.failed';
|
||
if (isAgentCancelled(agent)) return 'subagent.cancelled';
|
||
// Successful teammate launches use a different session mechanism and may
|
||
// complete without publishing readiness.
|
||
if (agent.status === 'completed') return undefined;
|
||
return 'subagent.creating';
|
||
}
|
||
|
||
export function getAgentDisplayStatus(
|
||
agent: ACPToolCall,
|
||
): ACPToolCall['status'] {
|
||
if (agent.status === 'failed') return 'failed';
|
||
if (isAgentCancelled(agent)) return 'failed';
|
||
return agent.status;
|
||
}
|
||
|
||
export function formatTokenCount(tokens: number): string {
|
||
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M tokens`;
|
||
if (tokens >= 1000)
|
||
return (tokens / 1000).toFixed(1).replace(/\.0$/, '') + 'k tokens';
|
||
return `${tokens} tokens`;
|
||
}
|
||
|
||
const DEFAULT_SUBAGENT_TYPE = 'general-purpose';
|
||
|
||
export function getAgentType(agent: ACPToolCall): string {
|
||
const taskExec = getTaskExecutionRecord(agent.rawOutput);
|
||
if (taskExec) {
|
||
const name = taskExec['subagentName'];
|
||
if (typeof name === 'string' && name) return name;
|
||
}
|
||
const subagentType = agent.args?.subagent_type;
|
||
if (typeof subagentType === 'string' && subagentType) return subagentType;
|
||
return agent.toolName === 'task' ? 'task' : DEFAULT_SUBAGENT_TYPE;
|
||
}
|
||
|
||
// 'task' is getAgentType's other untyped-agent fallback and has no i18n key.
|
||
export function isDefaultAgentType(agentType: string): boolean {
|
||
return (
|
||
agentType.toLowerCase() === DEFAULT_SUBAGENT_TYPE || agentType === 'task'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Locale-aware agent type display name. Looks up `agentType.<name>`
|
||
* (case-insensitive) via the translator; falls back to the raw name
|
||
* for user-defined agents that have no i18n entry.
|
||
*/
|
||
export function localizeAgentTypeName(
|
||
agentType: string,
|
||
t: (key: string, vars?: Record<string, string | number>) => string,
|
||
): string {
|
||
const keys = [
|
||
`agentType.${agentType}`,
|
||
`agentType.${agentType.toLowerCase()}`,
|
||
];
|
||
for (const key of keys) {
|
||
const translated = t(key);
|
||
if (translated !== key) return translated;
|
||
}
|
||
return agentType;
|
||
}
|
||
|
||
export function getAgentDescription(agent: ACPToolCall): string {
|
||
if (agent.title) {
|
||
const colonIdx = agent.title.indexOf(': ');
|
||
if (colonIdx > 0) return agent.title.slice(colonIdx + 2);
|
||
}
|
||
const desc = agent.args?.description;
|
||
if (typeof desc === 'string' && desc.trim()) return desc.trim();
|
||
const taskExec = getTaskExecutionRecord(agent.rawOutput);
|
||
const taskDesc = taskExec?.['taskDescription'];
|
||
if (typeof taskDesc === 'string' && taskDesc.trim()) return taskDesc.trim();
|
||
const prompt = agent.args?.prompt;
|
||
if (typeof prompt === 'string' && prompt.trim()) {
|
||
return prompt.trim().split('\n')[0] ?? '';
|
||
}
|
||
return '';
|
||
}
|
||
|
||
export function getAgentCurrentToolHint(
|
||
agent: ACPToolCall,
|
||
t: (key: string, vars?: Record<string, string | number>) => string,
|
||
): string {
|
||
if (agent.status !== 'in_progress') return '';
|
||
const subs = agent.subTools;
|
||
if (!subs || subs.length === 0) return '';
|
||
const last = subs[subs.length - 1];
|
||
if (last.status !== 'in_progress' && last.status !== 'pending') return '';
|
||
let hint = localizeToolDisplayName(last.toolName ?? '', t);
|
||
if (last.title) {
|
||
const colonIdx = last.title.indexOf(': ');
|
||
hint += ' ' + (colonIdx > 0 ? last.title.slice(colonIdx + 2) : last.title);
|
||
} else if (last.args?.command) {
|
||
hint += ' ' + String(last.args.command);
|
||
} else if (last.args?.file_path) {
|
||
hint += ' ' + String(last.args.file_path);
|
||
}
|
||
return truncateText(hint, 50);
|
||
}
|
||
|
||
interface AdvisorReviewOutput {
|
||
verdict: string;
|
||
risks: string;
|
||
missingEvidence: string;
|
||
recommendation: string;
|
||
}
|
||
|
||
function getAdvisorReview(rawOutput: unknown): AdvisorReviewOutput | undefined {
|
||
if (!rawOutput || typeof rawOutput !== 'object') return undefined;
|
||
const raw = rawOutput as Record<string, unknown>;
|
||
if (raw.type !== 'advisor_review') return undefined;
|
||
if (
|
||
typeof raw.verdict !== 'string' ||
|
||
typeof raw.risks !== 'string' ||
|
||
typeof raw.missingEvidence !== 'string' ||
|
||
typeof raw.recommendation !== 'string'
|
||
) {
|
||
return undefined;
|
||
}
|
||
return raw as unknown as AdvisorReviewOutput;
|
||
}
|
||
|
||
export function extractRawOutputText(rawOutput: unknown): string | null {
|
||
if (!rawOutput) return null;
|
||
if (typeof rawOutput === 'string') return rawOutput;
|
||
if (typeof rawOutput !== 'object') return null;
|
||
|
||
const raw = rawOutput as Record<string, unknown>;
|
||
const advisorReview = getAdvisorReview(raw);
|
||
if (advisorReview) {
|
||
const fields = [
|
||
['Verdict', advisorReview.verdict],
|
||
['Risks', advisorReview.risks],
|
||
['Missing evidence', advisorReview.missingEvidence],
|
||
['Recommendation', advisorReview.recommendation],
|
||
] as const;
|
||
return fields.map(([label, value]) => `## ${label}\n${value}`).join('\n\n');
|
||
}
|
||
if (typeof raw.output === 'string') return raw.output;
|
||
if (typeof raw.stdout === 'string') return raw.stdout;
|
||
if (typeof raw.content === 'string') return raw.content;
|
||
if (typeof raw.reason === 'string') return raw.reason;
|
||
if (typeof raw.terminateReason === 'string') return raw.terminateReason;
|
||
if (typeof raw.error === 'string') return raw.error;
|
||
if (typeof raw.text === 'string') return raw.text;
|
||
return null;
|
||
}
|