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_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,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// 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,26 +399,28 @@ 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 =
|
||||||
tool_calls: children.map(({ info: ci, toolName: cn }) => {
|
isAgent && children.length > 0
|
||||||
const cFinal =
|
? {
|
||||||
ci.status === "completed" || ci.status === "failed"
|
tool_calls: children.map(({ info: ci, toolName: cn }) => {
|
||||||
const cOutput =
|
const cFinal =
|
||||||
ci.raw_output_chunks.length > 0
|
ci.status === "completed" || ci.status === "failed"
|
||||||
? getJoinedChunks(ci.raw_output_chunks)
|
const cOutput =
|
||||||
: ci.content
|
ci.raw_output_chunks.length > 0
|
||||||
return {
|
? getJoinedChunks(ci.raw_output_chunks)
|
||||||
tool_name: cn,
|
: ci.content
|
||||||
input_preview: ci.raw_input?.substring(0, 500) ?? null,
|
return {
|
||||||
output_preview: cFinal
|
tool_name: cn,
|
||||||
? (cOutput?.substring(0, 500) ?? null)
|
input_preview: ci.raw_input?.substring(0, 500) ?? null,
|
||||||
: null,
|
output_preview: cFinal
|
||||||
is_error: ci.status === "failed",
|
? (cOutput?.substring(0, 500) ?? null)
|
||||||
}
|
: null,
|
||||||
}),
|
is_error: ci.status === "failed",
|
||||||
}
|
}
|
||||||
: undefined
|
}),
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
if (isFinalState) {
|
if (isFinalState) {
|
||||||
currentBlocks.push({
|
currentBlocks.push({
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user