feat(acp): forward meta/locations fields and use parentToolUseId for precise Agent child grouping
Forward the previously-dropped `locations` and `meta` fields from ACP SDK ToolCall/ToolCallUpdate events through to the frontend. The meta field carries `claudeCode.parentToolUseId` which enables precise parent-child matching for concurrent Agent tool calls during streaming. - Forward locations/meta in Rust AcpEvent types and connection handlers - Use parentToolUseId for exact agent→child mapping, with position-based fallback for agents that don't provide it (Codex, OpenCode) - Replace `any` types with proper ToolCallMeta / unknown types - Add runtime guards for meta field parsing (defensive against unexpected shapes from different agents) - Cache inferLiveToolName results per tool_call_id to avoid redundant computation across Phase 1 and Phase 2 - Lazy-construct agentStats only when children exist
This commit is contained in:
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,6 +63,10 @@ pub enum AcpEvent {
|
||||
content: Option<String>,
|
||||
raw_input: Option<String>,
|
||||
raw_output: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
locations: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
meta: Option<serde_json::Value>,
|
||||
},
|
||||
/// Tool call status/content updated
|
||||
ToolCallUpdate {
|
||||
@@ -75,6 +79,10 @@ pub enum AcpEvent {
|
||||
raw_output: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
raw_output_append: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
locations: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
meta: Option<serde_json::Value>,
|
||||
},
|
||||
/// Agent requests permission
|
||||
PermissionRequest {
|
||||
|
||||
@@ -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<string, unknown> | 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<number | null>(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
|
||||
|
||||
@@ -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<string>()
|
||||
|
||||
// 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<string, string>()
|
||||
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<string>()
|
||||
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<string, unknown>).claudeCode
|
||||
if (cc && typeof cc === "object" && "parentToolUseId" in cc) {
|
||||
const pid = (cc as Record<string, unknown>).parentToolUseId
|
||||
if (typeof pid === "string") parentId = pid
|
||||
}
|
||||
} else if (activeAgentId && activeAgentCompleted) {
|
||||
}
|
||||
|
||||
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 (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,7 +399,9 @@ function buildStreamingTurnsFromLiveMessage(
|
||||
const children = isAgent
|
||||
? (agentChildren.get(block.info.tool_call_id) ?? [])
|
||||
: []
|
||||
const agentStats: AgentExecutionStats | undefined = isAgent
|
||||
// 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 =
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user