feat(ui): user-friendly tool and agent labels in the chat tool timeline (#3054)

This commit is contained in:
Steven Enamakel
2026-05-30 21:00:42 -07:00
committed by GitHub
parent 36a3f3fa65
commit a997d8ad17
17 changed files with 412 additions and 22 deletions
@@ -91,6 +91,49 @@ describe('subMascotModelsFromTimeline', () => {
])
).toHaveLength(0);
});
it('prefers subagent displayName from registry over humanized agent id', () => {
const models = subMascotModelsFromTimeline([
subagentEntry({
id: 'thread-1:subagent:sub-1:code_executor',
name: 'subagent:code_executor',
subagent: {
taskId: 'sub-1',
agentId: 'code_executor',
displayName: 'Code Executor',
toolCalls: [],
},
}),
]);
expect(models).toHaveLength(1);
expect(models[0].label).toBe('Code Executor');
});
it('prefers entry displayName when subagent displayName is absent', () => {
const models = subMascotModelsFromTimeline([
subagentEntry({
displayName: 'Custom Label',
subagent: { taskId: 'sub-1', agentId: 'researcher', toolCalls: [] },
}),
]);
expect(models[0].label).toBe('Custom Label');
});
it('uses formatToolName for running child tool activity', () => {
const models = subMascotModelsFromTimeline([
subagentEntry({
subagent: {
taskId: 'sub-1',
agentId: 'researcher',
toolCalls: [{ callId: 'c1', toolName: 'web_fetch', status: 'running' }],
},
}),
]);
expect(models[0].activity).toBe('Using Fetching');
});
});
describe('<SubMascotLayer />', () => {
+4 -3
View File
@@ -2,6 +2,7 @@ import debug from 'debug';
import { type FC, useMemo } from 'react';
import type { ToolTimelineEntry, ToolTimelineEntryStatus } from '../../store/chatRuntimeSlice';
import { formatToolName } from '../../utils/toolTimelineFormatting';
import { type MascotFace, RiveMascot } from './Mascot';
import type { MascotColor } from './Mascot/mascotPalette';
@@ -49,7 +50,7 @@ function truncateActivity(value: string): string {
return `${trimmed.slice(0, ACTIVITY_LIMIT - 3).trimEnd()}...`;
}
function humanizeIdentifier(value: string): string {
function humanizeAgentId(value: string): string {
const cleaned = value
.replace(/^subagent:/, '')
.replace(/[_-]+/g, ' ')
@@ -84,7 +85,7 @@ function activityForEntry(entry: ToolTimelineEntry): string {
const lastRunningTool = [...subagent.toolCalls].reverse().find(call => call.status === 'running');
if (lastRunningTool) {
return `Using ${humanizeIdentifier(lastRunningTool.toolName)}`;
return `Using ${formatToolName(lastRunningTool.toolName)}`;
}
if (subagent.childIteration) {
@@ -119,7 +120,7 @@ export function subMascotModelsFromTimeline(entries: ToolTimelineEntry[]): SubMa
return {
id: entry.id,
agentId,
label: humanizeIdentifier(agentId),
label: entry.subagent?.displayName ?? entry.displayName ?? humanizeAgentId(agentId),
status: entry.status,
face: faceForStatus(entry.status),
activity: activityForEntry(entry),
@@ -1,6 +1,6 @@
import { useT } from '../../../lib/i18n/I18nContext';
import type { SubagentActivity, ToolTimelineEntry } from '../../../store/chatRuntimeSlice';
import { formatTimelineEntry } from '../../../utils/toolTimelineFormatting';
import { formatTimelineEntry, formatToolName } from '../../../utils/toolTimelineFormatting';
import { parseWorkerThreadRef } from '../utils/workerThreadRef';
import { WorkerThreadRefCard, type WorkerThreadStatus } from './WorkerThreadRefCard';
@@ -115,8 +115,8 @@ export function SubagentActivityBlock({
className="flex items-center gap-1.5"
data-testid="subagent-tool-call">
<span className={`text-[9px] ${tone}`}></span>
<span className="font-mono text-[10px] text-stone-700 dark:text-neutral-200">
{call.toolName}
<span className="text-[10px] text-stone-700 dark:text-neutral-200">
{formatToolName(call.toolName)}
</span>
{call.iteration != null ? (
<span className="text-[9px] text-stone-400 dark:text-neutral-500">
@@ -53,7 +53,7 @@ describe('SubagentActivityBlock', () => {
expect(block.textContent).toContain('4.2s');
});
it('renders one row per child tool call with status + timing', () => {
it('renders one row per child tool call with formatted names, status + timing', () => {
renderInStore(
<SubagentActivityBlock
subagent={{
@@ -62,18 +62,20 @@ describe('SubagentActivityBlock', () => {
toolCalls: [
{ callId: 'c1', toolName: 'web_search', status: 'success', elapsedMs: 312 },
{ callId: 'c2', toolName: 'composio_execute', status: 'running', iteration: 2 },
{ callId: 'c3', toolName: 'noisy', status: 'error', elapsedMs: 50 },
{ callId: 'c3', toolName: 'file_read', status: 'error', elapsedMs: 50 },
],
}}
/>
);
const calls = screen.getAllByTestId('subagent-tool-call');
expect(calls).toHaveLength(3);
expect(calls[0].textContent).toContain('web_search');
expect(calls[0].textContent).toContain('Searching the web');
expect(calls[0].textContent).toContain('success');
expect(calls[0].textContent).toContain('312ms');
expect(calls[1].textContent).toContain('Composio Execute');
expect(calls[1].textContent).toContain('running');
expect(calls[1].textContent).toContain('·t2');
expect(calls[2].textContent).toContain('Reading file');
expect(calls[2].textContent).toContain('error');
});
@@ -154,7 +156,7 @@ describe('ToolTimelineBlock — subagent rendering', () => {
const calls = screen.getAllByTestId('subagent-tool-call');
expect(calls).toHaveLength(1);
expect(calls[0].textContent).toContain('web_search');
expect(calls[0].textContent).toContain('Searching the web');
expect(screen.getByTestId('subagent-activity').textContent).toContain('turn 1/5');
});
@@ -485,6 +485,7 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
subagent: {
taskId: event.skill_id,
agentId: event.tool_name,
displayName: event.subagent?.display_name,
workerThreadId: event.subagent?.worker_thread_id,
mode: event.subagent?.mode,
dedicatedThread: event.subagent?.dedicated_thread,
+2
View File
@@ -198,6 +198,8 @@ export interface SubagentProgressDetail {
output_chars?: number;
/** Persistent worker sub-thread id backing the delegation (on `subagent_spawned`). */
worker_thread_id?: string;
/** Human-readable display name from the agent registry. */
display_name?: string;
}
/** Extended payload for `subagent_spawned`. */
+2
View File
@@ -38,6 +38,8 @@ export interface SubagentActivity {
taskId: string;
/** Sub-agent definition id (e.g. `researcher`). */
agentId: string;
/** Human-readable display name from the agent registry (e.g. "Researcher"). */
displayName?: string;
/**
* Persistent worker sub-thread id (`worker-<uuid>`) backing this
* delegation, when one was created. Lets the drawer reopen the full
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import type { ToolTimelineEntry } from '../../store/chatRuntimeSlice';
import { formatTimelineEntry } from '../toolTimelineFormatting';
import { formatTimelineEntry, formatToolName } from '../toolTimelineFormatting';
function entry(overrides: Partial<ToolTimelineEntry>): ToolTimelineEntry {
return { id: 'x', name: 'delegate_notion', round: 1, status: 'running', ...overrides };
@@ -126,4 +126,122 @@ describe('formatTimelineEntry', () => {
detail: undefined,
});
});
it('formats shell tool with truncated command detail', () => {
expect(
formatTimelineEntry(
entry({ name: 'shell', argsBuffer: JSON.stringify({ command: 'cargo test --lib' }) })
)
).toEqual({ title: 'Running command', detail: 'cargo test --lib' });
});
it('formats web_fetch with hostname in title', () => {
expect(
formatTimelineEntry(
entry({
name: 'web_fetch',
argsBuffer: JSON.stringify({ url: 'https://docs.example.com/api/v2/users' }),
})
)
).toEqual({
title: 'Fetching docs.example.com',
detail: 'https://docs.example.com/api/v2/users',
});
});
it('formats web_search with query in title', () => {
expect(
formatTimelineEntry(
entry({ name: 'web_search', argsBuffer: JSON.stringify({ query: 'rust async trait' }) })
)
).toEqual({ title: 'Searching: rust async trait' });
});
it('formats file_read with shortened path', () => {
expect(
formatTimelineEntry(
entry({
name: 'file_read',
argsBuffer: JSON.stringify({ path: 'src/openhuman/agent/progress.rs' }),
})
)
).toEqual({ title: 'Reading file', detail: '…/agent/progress.rs' });
});
it('formats edit tool with file path', () => {
expect(
formatTimelineEntry(
entry({
name: 'edit',
argsBuffer: JSON.stringify({ file_path: 'app/src/components/App.tsx' }),
})
)
).toEqual({ title: 'Editing file', detail: '…/components/App.tsx' });
});
it('formats grep with pattern in title', () => {
expect(
formatTimelineEntry(
entry({ name: 'grep', argsBuffer: JSON.stringify({ pattern: 'SubagentSpawned' }) })
)
).toEqual({ title: 'Searching: SubagentSpawned' });
});
it('formats git_operations with subcommand', () => {
expect(
formatTimelineEntry(
entry({ name: 'git_operations', argsBuffer: JSON.stringify({ command: 'diff --stat' }) })
)
).toEqual({ title: 'Git diff', detail: 'diff --stat' });
});
it('formats screenshot as a simple label', () => {
expect(formatTimelineEntry(entry({ name: 'screenshot' }))).toEqual({
title: 'Taking screenshot',
});
});
it('formats glob with pattern detail', () => {
expect(
formatTimelineEntry(
entry({ name: 'glob', argsBuffer: JSON.stringify({ pattern: '**/*.test.ts' }) })
)
).toEqual({ title: 'Finding: **/*.test.ts' });
});
it('formats list with directory path', () => {
expect(
formatTimelineEntry(
entry({ name: 'list', argsBuffer: JSON.stringify({ path: 'src/openhuman/tools' }) })
)
).toEqual({ title: 'Listing directory', detail: 'src/openhuman/tools' });
});
it('formats browser_open with hostname', () => {
expect(
formatTimelineEntry(
entry({
name: 'browser_open',
argsBuffer: JSON.stringify({ url: 'https://github.com/tinyhumansai/openhuman' }),
})
)
).toEqual({ title: 'Browsing github.com' });
});
});
describe('formatToolName', () => {
it('returns human-readable names for known tools', () => {
expect(formatToolName('shell')).toBe('Running command');
expect(formatToolName('web_fetch')).toBe('Fetching');
expect(formatToolName('file_read')).toBe('Reading file');
expect(formatToolName('edit')).toBe('Editing file');
expect(formatToolName('grep')).toBe('Searching code');
expect(formatToolName('git_operations')).toBe('Git operation');
expect(formatToolName('screenshot')).toBe('Taking screenshot');
expect(formatToolName('lsp')).toBe('Code intelligence');
});
it('falls back to humanized identifier for unknown tools', () => {
expect(formatToolName('custom_fancy_tool')).toBe('Custom Fancy Tool');
});
});
+210 -9
View File
@@ -4,6 +4,79 @@ interface ParsedToolArgs {
agent_id?: string;
prompt?: string;
toolkit?: string;
command?: string;
url?: string;
path?: string;
file_path?: string;
pattern?: string;
query?: string;
tool_name?: string;
}
const TOOL_DISPLAY_NAMES: Record<string, string> = {
shell: 'Running command',
node_exec: 'Running command',
npm_exec: 'Running command',
web_fetch: 'Fetching',
http_request: 'Fetching',
curl: 'Fetching',
web_search: 'Searching the web',
gitbooks_search: 'Searching docs',
file_read: 'Reading file',
file_write: 'Writing file',
edit: 'Editing file',
apply_patch: 'Applying patch',
grep: 'Searching code',
glob: 'Finding files',
list: 'Listing directory',
read_diff: 'Reading diff',
git_operations: 'Git operation',
browser: 'Browsing',
browser_open: 'Opening browser',
screenshot: 'Taking screenshot',
image_info: 'Analyzing image',
install_tool: 'Installing tool',
lsp: 'Code intelligence',
keyboard: 'Typing',
mouse: 'Clicking',
csv_export: 'Exporting CSV',
update_memory_md: 'Updating memory',
read_workspace_state: 'Reading workspace',
current_time: 'Checking time',
schedule: 'Scheduling',
detect_tools: 'Detecting tools',
tool_stats: 'Tool statistics',
vault_write_markdown: 'Writing to vault',
run_linter: 'Running linter',
run_tests: 'Running tests',
proxy_config: 'Configuring proxy',
update_check: 'Checking for updates',
update_apply: 'Applying update',
pushover: 'Sending notification',
insert_sql_record: 'Inserting record',
mcp_list_servers: 'Listing MCP servers',
mcp_list_tools: 'Listing MCP tools',
mcp_call_tool: 'Calling MCP tool',
mcp_setup_search: 'Searching MCP tools',
mcp_setup_get: 'Getting MCP tool',
mcp_setup_install_and_connect: 'Installing MCP server',
mcp_setup_request_secret: 'Requesting secret',
mcp_setup_test_connection: 'Testing connection',
polymarket: 'Checking markets',
gmail_unsubscribe: 'Unsubscribing',
gitbooks_get_page: 'Reading docs page',
audio_generate_podcast: 'Generating podcast',
audio_email_podcast: 'Emailing podcast',
audio_generate_and_email_podcast: 'Generating & emailing podcast',
composio_list_connections: 'Viewing your Connections',
};
/**
* Format a raw tool name into a short human-readable label.
* Used for subagent child tool rows and sub-mascot activity text.
*/
export function formatToolName(toolName: string): string {
return TOOL_DISPLAY_NAMES[toolName] ?? humanizeIdentifier(toolName);
}
export function formatTimelineEntry(entry: ToolTimelineEntry): { title: string; detail?: string } {
@@ -56,15 +129,6 @@ export function formatTimelineEntry(entry: ToolTimelineEntry): { title: string;
inferIntegrationNameFromPrompt(parsedArgs?.prompt) ??
inferIntegrationName(entry.name);
// The collapsed `delegate_to_integrations_agent` tool has no toolkit
// baked into its name; if we couldn't infer a provider from args or
// prompt, surface a generic connected-app label (matches the
// `spawn_subagent → integrations_agent` fallback above) instead of
// humanising the tool name into "To Integrations Agent". Unknown
// toolkit slugs from args fall back to a humanised toolkit label so
// composio integrations outside KNOWN_TOOLKIT_RE still render
// meaningfully (e.g. `slack_bot` → "Slack Bot") rather than the
// generic copy.
let title: string;
if (provider) {
title = integrationActivityTitle(provider);
@@ -80,6 +144,12 @@ export function formatTimelineEntry(entry: ToolTimelineEntry): { title: string;
return { title, detail: entry.detail ?? parsedArgs?.prompt };
}
// ── Tool-specific formatting with args-derived detail ──────────────
const toolDetail = formatToolDetail(entry.name, parsedArgs);
if (toolDetail) {
return { title: toolDetail.title, detail: toolDetail.detail ?? entry.detail };
}
return {
title: entry.displayName ?? humanizeIdentifier(entry.name),
detail: entry.detail ?? parsedArgs?.prompt,
@@ -90,6 +160,137 @@ export function promptFromArgsBuffer(argsBuffer?: string): string | undefined {
return parseToolArgs(argsBuffer)?.prompt?.trim() || undefined;
}
const MAX_DETAIL_LEN = 120;
function truncateDetail(value: string): string {
const cleaned = value.trim().replace(/\s+/g, ' ');
if (cleaned.length <= MAX_DETAIL_LEN) return cleaned;
return `${cleaned.slice(0, MAX_DETAIL_LEN - 1)}`;
}
function hostnameFromUrl(url: string): string | undefined {
try {
return new URL(url).hostname;
} catch {
return undefined;
}
}
function shortenPath(filePath: string): string {
const parts = filePath.split('/');
if (parts.length <= 3) return filePath;
return `…/${parts.slice(-2).join('/')}`;
}
function formatToolDetail(
name: string,
args: ParsedToolArgs | null
): { title: string; detail?: string } | null {
switch (name) {
case 'shell':
case 'node_exec':
case 'npm_exec': {
const cmd = args?.command?.trim();
return { title: 'Running command', detail: cmd ? truncateDetail(cmd) : undefined };
}
case 'web_fetch':
case 'http_request':
case 'curl': {
const url = args?.url?.trim();
const host = url ? hostnameFromUrl(url) : undefined;
return {
title: host ? `Fetching ${host}` : 'Fetching',
detail: url ? truncateDetail(url) : undefined,
};
}
case 'web_search': {
const query = args?.query?.trim();
return { title: query ? `Searching: ${truncateDetail(query)}` : 'Searching the web' };
}
case 'gitbooks_search': {
const query = args?.query?.trim();
return { title: query ? `Searching docs: ${truncateDetail(query)}` : 'Searching docs' };
}
case 'file_read': {
const p = args?.path?.trim() ?? args?.file_path?.trim();
return { title: 'Reading file', detail: p ? shortenPath(p) : undefined };
}
case 'file_write':
case 'vault_write_markdown': {
const p = args?.path?.trim() ?? args?.file_path?.trim();
return { title: 'Writing file', detail: p ? shortenPath(p) : undefined };
}
case 'edit':
case 'apply_patch': {
const p = args?.path?.trim() ?? args?.file_path?.trim();
return { title: 'Editing file', detail: p ? shortenPath(p) : undefined };
}
case 'grep': {
const pat = args?.pattern?.trim();
return { title: pat ? `Searching: ${truncateDetail(pat)}` : 'Searching code' };
}
case 'glob': {
const pat = args?.pattern?.trim();
return { title: pat ? `Finding: ${truncateDetail(pat)}` : 'Finding files' };
}
case 'list': {
const p = args?.path?.trim();
return { title: 'Listing directory', detail: p ? shortenPath(p) : undefined };
}
case 'git_operations': {
const cmd = args?.command?.trim();
if (cmd) {
const verb = cmd.split(/\s+/)[0];
return { title: `Git ${verb}`, detail: truncateDetail(cmd) };
}
return { title: 'Git operation' };
}
case 'browser':
case 'browser_open': {
const url = args?.url?.trim();
const host = url ? hostnameFromUrl(url) : undefined;
return { title: host ? `Browsing ${host}` : 'Browsing' };
}
case 'screenshot':
return { title: 'Taking screenshot' };
case 'image_info':
return { title: 'Analyzing image' };
case 'install_tool': {
const tn = args?.tool_name?.trim();
return { title: tn ? `Installing ${tn}` : 'Installing tool' };
}
case 'lsp':
return { title: 'Code intelligence' };
case 'run_tests':
return { title: 'Running tests' };
case 'run_linter':
return { title: 'Running linter' };
case 'read_diff':
return { title: 'Reading diff' };
default:
return null;
}
}
/**
* Recognise the small set of known integration toolkit slugs. Used to
* gate `inferIntegrationName` so unknown `delegate_<x>` names (e.g.
+5
View File
@@ -256,6 +256,11 @@ pub struct SubagentProgressDetail {
/// uses it to reopen the full parent↔subagent conversation from memory.
#[serde(skip_serializing_if = "Option::is_none")]
pub worker_thread_id: Option<String>,
/// Human-readable display name from the agent registry (e.g.
/// "Researcher", "Coding Agent"). The frontend uses this for
/// consistent agent labels across timeline, sub-mascots, and drawer.
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
}
#[derive(Debug, Deserialize)]
+4
View File
@@ -73,6 +73,10 @@ pub enum AgentProgress {
/// full parent↔subagent conversation from memory after the live
/// turn ends. `None` for live-only runs (no parent context).
worker_thread_id: Option<String>,
/// Human-readable display name from the agent registry (e.g.
/// "Researcher", "Coding Agent"). Falls back to `agent_id` in
/// the UI when absent.
display_name: Option<String>,
},
/// A sub-agent completed successfully.
+4
View File
@@ -414,6 +414,9 @@ impl AgentOrchestrationSession {
});
if let Some(progress) = parent.on_progress.clone() {
let resolved_display_name = AgentDefinitionRegistry::global()
.and_then(|reg| reg.get(&agent_id))
.map(|def| def.display_name().to_string());
let _ = progress
.send(AgentProgress::SubagentSpawned {
agent_id: agent_id.clone(),
@@ -422,6 +425,7 @@ impl AgentOrchestrationSession {
dedicated_thread: false,
prompt_chars: prompt.chars().count(),
worker_thread_id: None,
display_name: resolved_display_name,
})
.await;
}
@@ -58,6 +58,7 @@ pub(crate) async fn dispatch_subagent(
dedicated_thread: false,
prompt_chars: prompt.chars().count(),
worker_thread_id: None,
display_name: Some(definition.display_name().to_string()),
})
.await;
}
@@ -266,6 +266,7 @@ impl Tool for SpawnParallelAgentsTool {
dedicated_thread: false,
prompt_chars: prompt.chars().count(),
worker_thread_id: None,
display_name: Some(definition.display_name().to_string()),
})
.await
{
@@ -433,6 +433,7 @@ impl Tool for SpawnSubagentTool {
dedicated_thread,
prompt_chars: prompt.chars().count(),
worker_thread_id: worker_thread_id.clone(),
display_name: Some(definition.display_name().to_string()),
})
.await;
}
+4 -1
View File
@@ -1154,13 +1154,15 @@ fn spawn_progress_bridge(
dedicated_thread,
prompt_chars,
worker_thread_id,
display_name,
} => {
let label = display_name.as_deref().unwrap_or(&agent_id);
publish_web_channel_event(WebChannelEvent {
event: "subagent_spawned".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
message: Some(format!("Sub-agent '{agent_id}' spawned")),
message: Some(format!("Sub-agent '{label}' spawned")),
tool_name: Some(agent_id),
skill_id: Some(task_id),
round: Some(round),
@@ -1169,6 +1171,7 @@ fn spawn_progress_bridge(
dedicated_thread: Some(dedicated_thread),
prompt_chars: Some(prompt_chars as u64),
worker_thread_id,
display_name,
..Default::default()
}),
..Default::default()
+2 -1
View File
@@ -145,6 +145,7 @@ impl TurnStateMirror {
mode,
dedicated_thread,
worker_thread_id,
display_name,
..
} => {
self.state.phase = Some(TurnPhase::Subagent);
@@ -155,7 +156,7 @@ impl TurnStateMirror {
round: self.state.iteration,
status: ToolTimelineStatus::Running,
args_buffer: None,
display_name: Some(agent_id.clone()),
display_name: display_name.clone().or_else(|| Some(agent_id.clone())),
detail: None,
source_tool_name: Some("spawn_subagent".to_string()),
subagent: Some(SubagentActivity {