diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 72c9d92..d8c3b10 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -1554,6 +1554,8 @@ fn emit_terminal_output_update( raw_input: None, raw_output: Some(output), raw_output_append: Some(append), + locations: None, + meta: None, }, ); } @@ -2501,6 +2503,12 @@ fn emit_conversation_update( .map(|text| resolve_live_tool_input(&text, cwd)); let raw_output = json_value_to_text(&tc.raw_output) .map(|text| structurize_live_output(&text)); + let locations = if tc.locations.is_empty() { + None + } else { + serde_json::to_value(&tc.locations).ok() + }; + let meta = tc.meta.map(serde_json::Value::Object); crate::web::event_bridge::emit_event( emitter, "acp://event", @@ -2513,6 +2521,8 @@ fn emit_conversation_update( content, raw_input, raw_output, + locations, + meta, }, ); } @@ -2526,6 +2536,13 @@ fn emit_conversation_update( .map(|text| resolve_live_tool_input(&text, cwd)); let raw_output = json_value_to_text(&tcu.fields.raw_output) .map(|text| structurize_live_output(&text)); + let locations = tcu + .fields + .locations + .as_ref() + .filter(|l| !l.is_empty()) + .and_then(|l| serde_json::to_value(l).ok()); + let meta = tcu.meta.clone().map(serde_json::Value::Object); crate::web::event_bridge::emit_event( emitter, "acp://event", @@ -2538,6 +2555,8 @@ fn emit_conversation_update( raw_input, raw_output, raw_output_append: None, + locations, + meta, }, ); } diff --git a/src-tauri/src/acp/types.rs b/src-tauri/src/acp/types.rs index e64a4e6..faa145a 100644 --- a/src-tauri/src/acp/types.rs +++ b/src-tauri/src/acp/types.rs @@ -63,6 +63,10 @@ pub enum AcpEvent { content: Option, raw_input: Option, raw_output: Option, + #[serde(skip_serializing_if = "Option::is_none")] + locations: Option, + #[serde(skip_serializing_if = "Option::is_none")] + meta: Option, }, /// Tool call status/content updated ToolCallUpdate { @@ -75,6 +79,10 @@ pub enum AcpEvent { raw_output: Option, #[serde(skip_serializing_if = "Option::is_none")] raw_output_append: Option, + #[serde(skip_serializing_if = "Option::is_none")] + locations: Option, + #[serde(skip_serializing_if = "Option::is_none")] + meta: Option, }, /// Agent requests permission PermissionRequest { diff --git a/src/contexts/acp-connections-context.tsx b/src/contexts/acp-connections-context.tsx index 2917fcd..6d0e2cc 100644 --- a/src/contexts/acp-connections-context.tsx +++ b/src/contexts/acp-connections-context.tsx @@ -55,6 +55,9 @@ import { useFolderContext } from "@/contexts/folder-context" // ── Shared types (re-exported for consumers) ── +/** ACP extensibility metadata attached to tool calls. */ +export type ToolCallMeta = Record | null + export interface ToolCallInfo { tool_call_id: string title: string @@ -64,6 +67,8 @@ export interface ToolCallInfo { raw_input: string | null raw_output_chunks: string[] raw_output_total_bytes: number + locations: unknown + meta: ToolCallMeta } export interface PendingPermission { @@ -149,6 +154,8 @@ type Action = content: string | null raw_input: string | null raw_output: string | null + locations: unknown + meta: ToolCallMeta } | { type: "TOOL_CALL_UPDATE" @@ -162,6 +169,8 @@ type Action = raw_input: string | null raw_output: string | null raw_output_append?: boolean + locations: unknown + meta: ToolCallMeta } | { type: "BATCH_TOOL_CALL_UPDATES" @@ -176,6 +185,10 @@ type Action = raw_input: string | null raw_output: string | null raw_output_append?: boolean + // eslint-disable-next-line @typescript-eslint/no-explicit-any + locations: any | null + // eslint-disable-next-line @typescript-eslint/no-explicit-any + meta: any | null }> } | { @@ -740,6 +753,8 @@ function connectionsReducer( raw_output_chunks: action.raw_output !== null ? [action.raw_output] : [], raw_output_total_bytes: action.raw_output?.length ?? 0, + locations: action.locations ?? null, + meta: action.meta ?? null, }, }, ] @@ -781,6 +796,8 @@ function connectionsReducer( raw_input: action.raw_input, raw_output_chunks: initialChunks, raw_output_total_bytes: initialBytes, + locations: action.locations ?? null, + meta: action.meta ?? null, }, }, ] @@ -835,6 +852,8 @@ function connectionsReducer( content: action.content ?? block.info.content, raw_input: action.raw_input ?? block.info.raw_input, raw_output_chunks: newChunks, + locations: action.locations ?? block.info.locations, + meta: action.meta ?? block.info.meta, raw_output_total_bytes: newTotalBytes, }, }, @@ -919,6 +938,8 @@ function connectionsReducer( raw_input: permissionToolInput, raw_output_chunks: [], raw_output_total_bytes: 0, + locations: null, + meta: null, }, }, ], @@ -1598,6 +1619,8 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { raw_input: string | null raw_output: string | null raw_output_append?: boolean + locations: unknown + meta: ToolCallMeta }> >([]) const toolCallUpdateRafId = useRef(null) @@ -1666,6 +1689,8 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { content: e.content, raw_input: e.raw_input, raw_output: e.raw_output, + locations: e.locations ?? null, + meta: (e.meta as ToolCallMeta) ?? null, }) break case "tool_call_update": @@ -1681,6 +1706,8 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { raw_input: e.raw_input, raw_output: e.raw_output, raw_output_append: e.raw_output_append, + locations: e.locations ?? null, + meta: (e.meta as ToolCallMeta) ?? null, }) scheduleToolCallUpdateFlush() break diff --git a/src/contexts/conversation-runtime-context.tsx b/src/contexts/conversation-runtime-context.tsx index 62c388e..8b24d35 100644 --- a/src/contexts/conversation-runtime-context.tsx +++ b/src/contexts/conversation-runtime-context.tsx @@ -255,53 +255,80 @@ function buildStreamingTurnsFromLiveMessage( liveMessage: LiveMessage ): BuiltStreamingTurns { // ── Phase 1: Identify agent → child relationships ────────────────── - // Position-based grouping: non-agent tool_calls after agent N (until - // the next agent or a content boundary) belong to agent N. When a new - // "agent" tool_call appears it starts a new capture region, so children - // between agent N and agent N+1 go to N. For concurrent agents (both - // starting before any children) the last agent receives all children — - // imperfect, but the DB-backed parser corrects it on the next refresh. + // Uses meta.claudeCode.parentToolUseId when available (precise), with + // position-based fallback for agents that don't provide it. const agentChildren = new Map< string, Array<{ info: ToolCallInfo; toolName: string }> >() const childToolCallIds = new Set() - // NOTE: The ACP SDK does not provide parent-child relationships between - // tool calls — there is no parent_id or context_id. When multiple agents - // run concurrently, all their child tool calls appear AFTER both agent - // blocks in the content array, making it impossible to determine which - // child belongs to which agent during streaming. The position-based - // heuristic below assigns children to the most recent preceding agent. - // The DB-backed parser corrects the grouping on the next data refresh. - let activeAgentId: string | null = null - let activeAgentCompleted = false + // Cache inferred tool names — inferLiveToolName is called per tool_call + // in both Phase 1 and Phase 2; caching avoids redundant computation. + const inferredNames = new Map() + const getToolName = (info: ToolCallInfo): string => { + const cached = inferredNames.get(info.tool_call_id) + if (cached !== undefined) return cached + const name = inferLiveToolName({ + title: info.title, + kind: info.kind, + rawInput: info.raw_input, + }) + inferredNames.set(info.tool_call_id, name) + return name + } + + // First pass: register all agent tool_call IDs + const agentIds = new Set() + for (const block of liveMessage.content) { + if (block.type !== "tool_call") continue + if (getToolName(block.info) === "agent") { + agentIds.add(block.info.tool_call_id) + agentChildren.set(block.info.tool_call_id, []) + } + } + + // Second pass: assign children using parentToolUseId or position fallback + let positionalAgentId: string | null = null + let positionalAgentCompleted = false for (const block of liveMessage.content) { if (block.type === "tool_call") { - const toolName = inferLiveToolName({ - title: block.info.title, - kind: block.info.kind, - rawInput: block.info.raw_input, - }) + const toolName = getToolName(block.info) if (toolName === "agent") { - // New agent boundary — starts a new capture region - activeAgentId = block.info.tool_call_id - if (!agentChildren.has(activeAgentId)) { - agentChildren.set(activeAgentId, []) - } - activeAgentCompleted = + positionalAgentId = block.info.tool_call_id + positionalAgentCompleted = block.info.status === "completed" || block.info.status === "failed" - } else if (activeAgentId) { - childToolCallIds.add(block.info.tool_call_id) - agentChildren.get(activeAgentId)!.push({ info: block.info, toolName }) + } else { + // Extract parentToolUseId from ACP meta (Claude Code embeds this + // under meta.claudeCode.parentToolUseId). Guard each access level + // to avoid crashes on unexpected shapes from other agents. + const meta = block.info.meta + let parentId: string | undefined + if (meta && typeof meta === "object" && "claudeCode" in meta) { + const cc = (meta as Record).claudeCode + if (cc && typeof cc === "object" && "parentToolUseId" in cc) { + const pid = (cc as Record).parentToolUseId + if (typeof pid === "string") parentId = pid + } + } + + const resolvedParent = + parentId && agentIds.has(parentId) ? parentId : positionalAgentId // fallback + + if (resolvedParent) { + childToolCallIds.add(block.info.tool_call_id) + agentChildren + .get(resolvedParent)! + .push({ info: block.info, toolName }) + } } - } else if (activeAgentId && activeAgentCompleted) { + } else if (positionalAgentId && positionalAgentCompleted) { // A text/thinking/plan block after a completed agent means the main - // agent is producing new content — stop capturing children. - activeAgentId = null - activeAgentCompleted = false + // agent is producing new content — stop position-based capture. + positionalAgentId = null + positionalAgentCompleted = false } } @@ -349,11 +376,7 @@ function buildStreamingTurnsFromLiveMessage( // Skip child tool calls — they are nested inside Agent cards if (childToolCallIds.has(block.info.tool_call_id)) break - const toolName = inferLiveToolName({ - title: block.info.title, - kind: block.info.kind, - rawInput: block.info.raw_input, - }) + const toolName = getToolName(block.info) currentBlocks.push({ type: "tool_use", tool_use_id: block.info.tool_call_id, @@ -376,26 +399,28 @@ function buildStreamingTurnsFromLiveMessage( const children = isAgent ? (agentChildren.get(block.info.tool_call_id) ?? []) : [] - const agentStats: AgentExecutionStats | undefined = isAgent - ? { - tool_calls: children.map(({ info: ci, toolName: cn }) => { - const cFinal = - ci.status === "completed" || ci.status === "failed" - const cOutput = - ci.raw_output_chunks.length > 0 - ? getJoinedChunks(ci.raw_output_chunks) - : ci.content - return { - tool_name: cn, - input_preview: ci.raw_input?.substring(0, 500) ?? null, - output_preview: cFinal - ? (cOutput?.substring(0, 500) ?? null) - : null, - is_error: ci.status === "failed", - } - }), - } - : undefined + // Lazy: only construct agentStats when there are children to show + const agentStats: AgentExecutionStats | undefined = + isAgent && children.length > 0 + ? { + tool_calls: children.map(({ info: ci, toolName: cn }) => { + const cFinal = + ci.status === "completed" || ci.status === "failed" + const cOutput = + ci.raw_output_chunks.length > 0 + ? getJoinedChunks(ci.raw_output_chunks) + : ci.content + return { + tool_name: cn, + input_preview: ci.raw_input?.substring(0, 500) ?? null, + output_preview: cFinal + ? (cOutput?.substring(0, 500) ?? null) + : null, + is_error: ci.status === "failed", + } + }), + } + : undefined if (isFinalState) { currentBlocks.push({ diff --git a/src/lib/types.ts b/src/lib/types.ts index 899c9f9..bfb2a06 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -402,6 +402,8 @@ export type AcpEvent = content: string | null raw_input: string | null raw_output: string | null + locations?: unknown + meta?: unknown } | { type: "tool_call_update" @@ -413,6 +415,8 @@ export type AcpEvent = raw_input: string | null raw_output: string | null raw_output_append?: boolean + locations?: unknown + meta?: unknown } | { type: "permission_request"