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:
xintaofei
2026-04-17 08:24:12 +08:00
parent 6763814a92
commit 834340e536
5 changed files with 141 additions and 58 deletions

View File

@@ -1554,6 +1554,8 @@ fn emit_terminal_output_update(
raw_input: None, raw_input: None,
raw_output: Some(output), raw_output: Some(output),
raw_output_append: Some(append), 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)); .map(|text| resolve_live_tool_input(&text, cwd));
let raw_output = json_value_to_text(&tc.raw_output) let raw_output = json_value_to_text(&tc.raw_output)
.map(|text| structurize_live_output(&text)); .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( crate::web::event_bridge::emit_event(
emitter, emitter,
"acp://event", "acp://event",
@@ -2513,6 +2521,8 @@ fn emit_conversation_update(
content, content,
raw_input, raw_input,
raw_output, raw_output,
locations,
meta,
}, },
); );
} }
@@ -2526,6 +2536,13 @@ fn emit_conversation_update(
.map(|text| resolve_live_tool_input(&text, cwd)); .map(|text| resolve_live_tool_input(&text, cwd));
let raw_output = json_value_to_text(&tcu.fields.raw_output) let raw_output = json_value_to_text(&tcu.fields.raw_output)
.map(|text| structurize_live_output(&text)); .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( crate::web::event_bridge::emit_event(
emitter, emitter,
"acp://event", "acp://event",
@@ -2538,6 +2555,8 @@ fn emit_conversation_update(
raw_input, raw_input,
raw_output, raw_output,
raw_output_append: None, raw_output_append: None,
locations,
meta,
}, },
); );
} }

View File

@@ -63,6 +63,10 @@ pub enum AcpEvent {
content: Option<String>, content: Option<String>,
raw_input: Option<String>, raw_input: Option<String>,
raw_output: 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 /// Tool call status/content updated
ToolCallUpdate { ToolCallUpdate {
@@ -75,6 +79,10 @@ pub enum AcpEvent {
raw_output: Option<String>, raw_output: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
raw_output_append: Option<bool>, 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 /// Agent requests permission
PermissionRequest { PermissionRequest {

View File

@@ -55,6 +55,9 @@ import { useFolderContext } from "@/contexts/folder-context"
// ── Shared types (re-exported for consumers) ── // ── Shared types (re-exported for consumers) ──
/** ACP extensibility metadata attached to tool calls. */
export type ToolCallMeta = Record<string, unknown> | null
export interface ToolCallInfo { export interface ToolCallInfo {
tool_call_id: string tool_call_id: string
title: string title: string
@@ -64,6 +67,8 @@ export interface ToolCallInfo {
raw_input: string | null raw_input: string | null
raw_output_chunks: string[] raw_output_chunks: string[]
raw_output_total_bytes: number raw_output_total_bytes: number
locations: unknown
meta: ToolCallMeta
} }
export interface PendingPermission { export interface PendingPermission {
@@ -149,6 +154,8 @@ type Action =
content: string | null content: string | null
raw_input: string | null raw_input: string | null
raw_output: string | null raw_output: string | null
locations: unknown
meta: ToolCallMeta
} }
| { | {
type: "TOOL_CALL_UPDATE" type: "TOOL_CALL_UPDATE"
@@ -162,6 +169,8 @@ type Action =
raw_input: string | null raw_input: string | null
raw_output: string | null raw_output: string | null
raw_output_append?: boolean raw_output_append?: boolean
locations: unknown
meta: ToolCallMeta
} }
| { | {
type: "BATCH_TOOL_CALL_UPDATES" type: "BATCH_TOOL_CALL_UPDATES"
@@ -176,6 +185,10 @@ type Action =
raw_input: string | null raw_input: string | null
raw_output: string | null raw_output: string | null
raw_output_append?: boolean 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: raw_output_chunks:
action.raw_output !== null ? [action.raw_output] : [], action.raw_output !== null ? [action.raw_output] : [],
raw_output_total_bytes: action.raw_output?.length ?? 0, 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_input: action.raw_input,
raw_output_chunks: initialChunks, raw_output_chunks: initialChunks,
raw_output_total_bytes: initialBytes, 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, content: action.content ?? block.info.content,
raw_input: action.raw_input ?? block.info.raw_input, raw_input: action.raw_input ?? block.info.raw_input,
raw_output_chunks: newChunks, raw_output_chunks: newChunks,
locations: action.locations ?? block.info.locations,
meta: action.meta ?? block.info.meta,
raw_output_total_bytes: newTotalBytes, raw_output_total_bytes: newTotalBytes,
}, },
}, },
@@ -919,6 +938,8 @@ function connectionsReducer(
raw_input: permissionToolInput, raw_input: permissionToolInput,
raw_output_chunks: [], raw_output_chunks: [],
raw_output_total_bytes: 0, raw_output_total_bytes: 0,
locations: null,
meta: null,
}, },
}, },
], ],
@@ -1598,6 +1619,8 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
raw_input: string | null raw_input: string | null
raw_output: string | null raw_output: string | null
raw_output_append?: boolean raw_output_append?: boolean
locations: unknown
meta: ToolCallMeta
}> }>
>([]) >([])
const toolCallUpdateRafId = useRef<number | null>(null) const toolCallUpdateRafId = useRef<number | null>(null)
@@ -1666,6 +1689,8 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
content: e.content, content: e.content,
raw_input: e.raw_input, raw_input: e.raw_input,
raw_output: e.raw_output, raw_output: e.raw_output,
locations: e.locations ?? null,
meta: (e.meta as ToolCallMeta) ?? null,
}) })
break break
case "tool_call_update": case "tool_call_update":
@@ -1681,6 +1706,8 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
raw_input: e.raw_input, raw_input: e.raw_input,
raw_output: e.raw_output, raw_output: e.raw_output,
raw_output_append: e.raw_output_append, raw_output_append: e.raw_output_append,
locations: e.locations ?? null,
meta: (e.meta as ToolCallMeta) ?? null,
}) })
scheduleToolCallUpdateFlush() scheduleToolCallUpdateFlush()
break break

View File

@@ -255,53 +255,80 @@ function buildStreamingTurnsFromLiveMessage(
liveMessage: LiveMessage liveMessage: LiveMessage
): BuiltStreamingTurns { ): BuiltStreamingTurns {
// ── Phase 1: Identify agent → child relationships ────────────────── // ── Phase 1: Identify agent → child relationships ──────────────────
// Position-based grouping: non-agent tool_calls after agent N (until // Uses meta.claudeCode.parentToolUseId when available (precise), with
// the next agent or a content boundary) belong to agent N. When a new // position-based fallback for agents that don't provide it.
// "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.
const agentChildren = new Map< const agentChildren = new Map<
string, string,
Array<{ info: ToolCallInfo; toolName: string }> Array<{ info: ToolCallInfo; toolName: string }>
>() >()
const childToolCallIds = new Set<string>() const childToolCallIds = new Set<string>()
// NOTE: The ACP SDK does not provide parent-child relationships between // Cache inferred tool names — inferLiveToolName is called per tool_call
// tool calls — there is no parent_id or context_id. When multiple agents // in both Phase 1 and Phase 2; caching avoids redundant computation.
// run concurrently, all their child tool calls appear AFTER both agent const inferredNames = new Map<string, string>()
// blocks in the content array, making it impossible to determine which const getToolName = (info: ToolCallInfo): string => {
// child belongs to which agent during streaming. The position-based const cached = inferredNames.get(info.tool_call_id)
// heuristic below assigns children to the most recent preceding agent. if (cached !== undefined) return cached
// The DB-backed parser corrects the grouping on the next data refresh. const name = inferLiveToolName({
let activeAgentId: string | null = null title: info.title,
let activeAgentCompleted = false 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) { for (const block of liveMessage.content) {
if (block.type === "tool_call") { if (block.type === "tool_call") {
const toolName = inferLiveToolName({ const toolName = getToolName(block.info)
title: block.info.title,
kind: block.info.kind,
rawInput: block.info.raw_input,
})
if (toolName === "agent") { if (toolName === "agent") {
// New agent boundary — starts a new capture region positionalAgentId = block.info.tool_call_id
activeAgentId = block.info.tool_call_id positionalAgentCompleted =
if (!agentChildren.has(activeAgentId)) {
agentChildren.set(activeAgentId, [])
}
activeAgentCompleted =
block.info.status === "completed" || block.info.status === "failed" block.info.status === "completed" || block.info.status === "failed"
} else if (activeAgentId) { } else {
childToolCallIds.add(block.info.tool_call_id) // Extract parentToolUseId from ACP meta (Claude Code embeds this
agentChildren.get(activeAgentId)!.push({ info: block.info, toolName }) // 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 // A text/thinking/plan block after a completed agent means the main
// agent is producing new content — stop capturing children. // agent is producing new content — stop position-based capture.
activeAgentId = null positionalAgentId = null
activeAgentCompleted = false positionalAgentCompleted = false
} }
} }
@@ -349,11 +376,7 @@ function buildStreamingTurnsFromLiveMessage(
// Skip child tool calls — they are nested inside Agent cards // Skip child tool calls — they are nested inside Agent cards
if (childToolCallIds.has(block.info.tool_call_id)) break if (childToolCallIds.has(block.info.tool_call_id)) break
const toolName = inferLiveToolName({ const toolName = getToolName(block.info)
title: block.info.title,
kind: block.info.kind,
rawInput: block.info.raw_input,
})
currentBlocks.push({ currentBlocks.push({
type: "tool_use", type: "tool_use",
tool_use_id: block.info.tool_call_id, tool_use_id: block.info.tool_call_id,
@@ -376,7 +399,9 @@ function buildStreamingTurnsFromLiveMessage(
const children = isAgent const children = isAgent
? (agentChildren.get(block.info.tool_call_id) ?? []) ? (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 }) => { tool_calls: children.map(({ info: ci, toolName: cn }) => {
const cFinal = const cFinal =

View File

@@ -402,6 +402,8 @@ export type AcpEvent =
content: string | null content: string | null
raw_input: string | null raw_input: string | null
raw_output: string | null raw_output: string | null
locations?: unknown
meta?: unknown
} }
| { | {
type: "tool_call_update" type: "tool_call_update"
@@ -413,6 +415,8 @@ export type AcpEvent =
raw_input: string | null raw_input: string | null
raw_output: string | null raw_output: string | null
raw_output_append?: boolean raw_output_append?: boolean
locations?: unknown
meta?: unknown
} }
| { | {
type: "permission_request" type: "permission_request"