Display delegate sub agents logs in UI (#7519)

Co-authored-by: Jack Amadeo <jackamadeo@squareup.com>
This commit is contained in:
Zane
2026-03-23 11:38:25 -07:00
committed by GitHub
parent 7823a8e6b9
commit ed5e1305d1
5 changed files with 196 additions and 28 deletions
@@ -22,7 +22,7 @@ use crate::session::SessionType;
use anyhow::Result;
use async_trait::async_trait;
use rmcp::model::{
CallToolResult, Content, Implementation, InitializeResult, JsonObject, ListToolsResult,
CallToolResult, Content, Implementation, InitializeResult, JsonObject, ListToolsResult, Meta,
ServerCapabilities, ServerNotification, Tool,
};
use serde::Deserialize;
@@ -857,7 +857,7 @@ impl SummonClient {
&self,
session_id: &str,
arguments: Option<JsonObject>,
) -> Result<Vec<Content>, String> {
) -> Result<CallToolResult, String> {
self.cleanup_completed_tasks().await;
let source_name = arguments
@@ -874,17 +874,27 @@ impl SummonClient {
let working_dir = self.get_working_dir(session_id).await;
if source_name.is_none() {
return self.handle_load_discovery(session_id, &working_dir).await;
return self
.handle_load_discovery(session_id, &working_dir)
.await
.map(CallToolResult::success);
}
let name = source_name.unwrap();
if is_session_id(name) {
return self.handle_load_task_result(name, cancel).await;
let content = self.handle_load_task_result(name, cancel).await?;
let mut meta = Meta::new();
meta.0.insert(
"subagent_session_id".to_string(),
serde_json::Value::String(name.to_string()),
);
return Ok(CallToolResult::success(content).with_meta(Some(meta)));
}
self.handle_load_source(session_id, name, &working_dir)
.await
.map(CallToolResult::success)
}
async fn handle_load_task_result(
@@ -1197,7 +1207,7 @@ impl SummonClient {
session_id: &str,
arguments: Option<JsonObject>,
cancellation_token: CancellationToken,
) -> Result<Vec<Content>, String> {
) -> Result<CallToolResult, String> {
self.cleanup_completed_tasks().await;
let params: DelegateParams = arguments
@@ -1229,7 +1239,13 @@ impl SummonClient {
}
if params.r#async {
return self.handle_async_delegate(session_id, params).await;
let (content, task_id) = self.handle_async_delegate(session_id, params).await?;
let mut meta = Meta::new();
meta.0.insert(
"subagent_session_id".to_string(),
serde_json::Value::String(task_id),
);
return Ok(CallToolResult::success(content).with_meta(Some(meta)));
}
let working_dir = session.working_dir.clone();
@@ -1273,6 +1289,8 @@ impl SummonClient {
Arc::new(Mutex::new(Vec::new())),
);
let subagent_session_id = subagent_session.id.clone();
let result = run_subagent_task(SubagentRunParams {
config: agent_config,
recipe,
@@ -1283,10 +1301,24 @@ impl SummonClient {
on_message: None,
notification_tx: Some(notif_tx),
})
.await
.map_err(|e| format!("Delegation failed: {}", e))?;
.await;
Ok(vec![Content::text(result)])
let mut meta = Meta::new();
meta.0.insert(
"subagent_session_id".to_string(),
serde_json::Value::String(subagent_session_id),
);
match result {
Ok(text) => {
Ok(CallToolResult::success(vec![Content::text(text)]).with_meta(Some(meta)))
}
Err(e) => Ok(CallToolResult::error(vec![Content::text(format!(
"Delegation failed: {}",
e
))])
.with_meta(Some(meta))),
}
}
fn validate_delegate_params(&self, params: &DelegateParams) -> Result<(), String> {
@@ -1678,7 +1710,7 @@ impl SummonClient {
&self,
session_id: &str,
params: DelegateParams,
) -> Result<Vec<Content>, String> {
) -> Result<(Vec<Content>, String), String> {
let task_count = self.background_tasks.lock().await.len();
let max_tasks = max_background_tasks();
if task_count >= max_tasks {
@@ -1786,11 +1818,12 @@ impl SummonClient {
.await
.insert(task_id.clone(), task);
Ok(vec![Content::text(format!(
let content = vec![Content::text(format!(
"Task {} started in background: \"{}\"\n\
Continue with other work. When you need the result, use load(source: \"{}\").",
task_id, description, task_id
))])
))];
Ok((content, task_id))
}
}
@@ -1833,20 +1866,29 @@ impl McpClientTrait for SummonClient {
cancellation_token: CancellationToken,
) -> Result<CallToolResult, Error> {
let session_id = &ctx.session_id;
let content = match name {
"load" => self.handle_load(session_id, arguments).await,
match name {
"load" => match self.handle_load(session_id, arguments).await {
Ok(result) => Ok(result),
Err(error) => Ok(CallToolResult::error(vec![Content::text(format!(
"Error: {}",
error
))])),
},
"delegate" => {
self.handle_delegate(session_id, arguments, cancellation_token)
match self
.handle_delegate(session_id, arguments, cancellation_token)
.await
{
Ok(result) => Ok(result),
Err(error) => Ok(CallToolResult::error(vec![Content::text(format!(
"Error: {}",
error
))])),
}
}
_ => Err(format!("Unknown tool: {}", name)),
};
match content {
Ok(content) => Ok(CallToolResult::success(content)),
Err(error) => Ok(CallToolResult::error(vec![Content::text(format!(
"Error: {}",
error
_ => Ok(CallToolResult::error(vec![Content::text(format!(
"Error: Unknown tool: {}",
name
))])),
}
}
@@ -13,7 +13,7 @@ import {
} from '../types/message';
import { cn, snakeToTitleCase } from '../utils';
import { LoadingStatus } from './ui/Dot';
import { ChevronRight, FlaskConical } from 'lucide-react';
import { ChevronRight, ExternalLink, FlaskConical } from 'lucide-react';
import { TooltipWrapper } from './settings/providers/subcomponents/buttons/TooltipWrapper';
import MCPUIResourceRenderer from './MCPUIResourceRenderer';
import { isUIResource } from '@mcp-ui/client';
@@ -33,6 +33,7 @@ type UiMeta = {
ui?: {
resourceUri?: string;
};
subagent_session_id?: string;
};
type ToolResultWithMeta = {
@@ -66,6 +67,33 @@ interface ToolCallWithResponseProps {
isApprovalClicked?: boolean;
}
function getSubagentSessionId(
toolResponse?: ToolResponseMessageContent,
notifications?: NotificationEvent[]
): string | null {
const result = toolResponse?.toolResult as ToolResultWithMeta | undefined;
const sessionId =
result?.status === 'success' ? result?.value?._meta?.subagent_session_id : undefined;
if (typeof sessionId === 'string') return sessionId;
// Fallback: extract from subagent notifications (e.g. when delegate was cancelled mid-stream)
if (notifications) {
for (const n of notifications) {
const message = n.message as { method?: string; params?: Record<string, unknown> };
if (message.method !== 'notifications/message') continue;
const data = message.params?.data;
if (data && typeof data === 'object' && 'type' in data && 'subagent_id' in data) {
const record = data as Record<string, unknown>;
if (record.type === 'subagent_tool_request' && typeof record.subagent_id === 'string') {
return record.subagent_id;
}
}
}
}
return null;
}
function getToolResultContent(toolResult: Record<string, unknown>): ContentBlock[] {
if (toolResult.status !== 'success') {
return [];
@@ -635,6 +663,25 @@ function ToolCallView({
}
break;
case 'delegate': {
if (args.instructions) {
const instr = getStringValue(args.instructions);
const truncated = instr.length > 80 ? instr.substring(0, 80) + '…' : instr;
return `delegating: ${truncated}`;
}
if (args.source) {
return `delegating to ${getStringValue(args.source)}`;
}
return 'delegating task';
}
case 'load': {
if (args.source) {
return `loading ${getStringValue(args.source)}`;
}
return 'loading source';
}
case 'final_output':
return 'final output';
@@ -790,6 +837,28 @@ function ToolCallView({
))}
</>
)}
{(() => {
if (loadingStatus === 'loading') return null;
const subagentSessionId = getSubagentSessionId(toolResponse, notifications);
if (!subagentSessionId) return null;
return (
<div className="border-t border-border-primary">
<button
onClick={() => {
window.electron.createChatWindow({
resumeSessionId: subagentSessionId,
viewType: 'pair',
});
}}
className="w-full flex items-center gap-2 px-4 py-2 text-xs text-text-secondary hover:text-text-primary hover:bg-background-secondary transition-colors cursor-pointer"
>
<ExternalLink className="w-3 h-3 flex-shrink-0" />
<span>View subagent session</span>
</button>
</div>
);
})()}
</ToolCallExpandable>
);
}
@@ -912,6 +981,34 @@ function ToolResultView({ result, isStartExpanded }: ToolResultViewProps) {
);
}
function SubagentLogEntry({ log }: { log: string }) {
const subagentMatch = log.match(/^\[subagent:(\w+)\]\s*([\s\S]*)/);
if (!subagentMatch) {
return <span className="font-sans text-sm text-textSubtle">{log}</span>;
}
const [, , rest] = subagentMatch;
const [firstLine, ...detailLines] = rest.split('\n');
const parts = firstLine.split(' | ');
const toolName = parts[0]?.trim() || firstLine;
const extensionName = parts[1]?.trim();
return (
<div className="font-sans text-sm text-textSubtle">
<span className="flex items-center gap-1.5">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 flex-shrink-0" />
<span className="font-medium text-text-secondary">{toolName}</span>
{extensionName && <span className="text-textSubtle opacity-60">· {extensionName}</span>}
</span>
{detailLines.length > 0 && (
<pre className="ml-3 mt-0.5 text-xs text-textSubtle whitespace-pre-wrap">
{detailLines.join('\n')}
</pre>
)}
</div>
);
}
function ToolLogsView({
logs,
working,
@@ -936,11 +1033,14 @@ function ToolLogsView({
// in this case, this is array of strings which once added do not change so this cuts
// down on the possibility of unwanted runs
const subagentLogCount = logs.filter((l) => l.startsWith('[subagent:')).length;
const labelText = subagentLogCount > 0 ? `Activity (${subagentLogCount})` : 'Logs';
return (
<ToolCallExpandable
label={
<span className="pl-4 py-1 font-sans text-sm flex items-center">
<span>Logs</span>
<span>{labelText}</span>
{working && (
<div className="mx-2 inline-block">
<span
@@ -960,9 +1060,7 @@ function ToolLogsView({
className={`flex flex-col items-start space-y-2 overflow-y-auto p-4 ${working ? 'max-h-[4rem]' : 'max-h-[20rem]'}`}
>
{logs.map((log, i) => (
<span key={i} className="font-sans text-sm text-textSubtle">
{log}
</span>
<SubagentLogEntry key={i} log={log} />
))}
</div>
</ToolCallExpandable>
@@ -0,0 +1,20 @@
export const Delegate = ({ className }: { className?: string }) => (
<svg
width="11"
height="11"
viewBox="0 0 11 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<rect width="11" height="11" rx="2" fill="#6366F1" />
<path
d="M3 4L6 5.5L3 7"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="8" cy="5.5" r="1" fill="white" />
</svg>
);
@@ -2,6 +2,7 @@ export { Archive } from './Archive';
export { Brain } from './Brain';
export { Camera } from './Camera';
export { Code2 } from './Code2';
export { Delegate } from './Delegate';
export { Eye } from './Eye';
export { FileEdit } from './FileEdit';
export { FilePlus } from './FilePlus';
+7
View File
@@ -4,6 +4,7 @@ import {
Brain,
Camera,
Code2,
Delegate,
Eye,
FileEdit,
FilePlus,
@@ -75,6 +76,12 @@ export const getToolIcon = (toolName: string): React.ComponentType<ToolIconProps
case 'docs_tool':
return FileText;
// Delegation Tools
case 'delegate':
return Delegate;
case 'load':
return Eye;
// Special Tools
case 'final_output':
return Tool; // Could be a checkmark icon if we had one