diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 840c7b5484..eeec2eaab2 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -5656,7 +5656,7 @@ pub(crate) fn claude_raw_sdk_session_meta( /// `forwardSubagentText`). Subagent text/thought chunks then stream with /// update-level `_meta.claudeCode.parentToolUseId` instead of being /// filtered; codeg routes them into the live Agent capsule (see -/// `claude_chunk_parent_tool_use_id`). The adapter checks strictly +/// `subagent_chunk_parent_tool_use_id`). The adapter checks strictly /// `=== true`, and a pre-0.63 binary ignores the unknown key, so this is /// inert everywhere it isn't understood. /// - Any agent that launches `cursor-agent … acp` (the built-in Cursor entry @@ -16204,11 +16204,11 @@ fn codebuddy_meta_marks_background( } /// True when a CodeBuddy thought/message `ContentChunk`'s own `_meta` marks the -/// chunk as sub-agent output (`codebuddy.ai/isSubagent`, or a -/// `codebuddy.ai/parentToolCallId` link to the Agent call). This is a precision -/// supplement to the open-sub-agent window — CodeBuddy is not confirmed to -/// populate chunk `_meta`, so suppression never relies on it alone. Gated on -/// CodeBuddy. +/// chunk as sub-agent output (`codebuddy.ai/isSubagent`). A precision +/// supplement to the open-sub-agent window, never relied on alone: chunks the +/// wire LINKED to their Agent call (`codebuddy.ai/parentToolCallId`, measured +/// populated on 2.164.0) are routed by `subagent_chunk_parent_tool_use_id` +/// before suppression is ever consulted. Gated on CodeBuddy. fn codebuddy_chunk_marks_subagent( agent_type: AgentType, meta: Option<&serde_json::Map>, @@ -16216,25 +16216,22 @@ fn codebuddy_chunk_marks_subagent( if agent_type != AgentType::CodeBuddy { return false; } - let Some(meta) = meta else { - return false; - }; - if meta.get("codebuddy.ai/isSubagent").and_then(|v| v.as_bool()) == Some(true) { - return true; - } - meta.get("codebuddy.ai/parentToolCallId") - .and_then(|v| v.as_str()) - .is_some_and(|s| !s.is_empty()) + meta.and_then(|m| m.get("codebuddy.ai/isSubagent")) + .and_then(|v| v.as_bool()) + == Some(true) } /// Whether a live thought/message chunk should be dropped from the top-level /// stream because it belongs to a CodeBuddy sub-agent (whose work is already /// represented by the Agent pill + its nested tool calls). /// -/// Claude Code is NOT handled here: its sub-agent chunks (claude-agent-acp -/// ≥0.63 with the `subagent-transcript` capability) arrive with a precise -/// per-chunk `_meta.claudeCode.parentToolUseId` and are forwarded WITH that -/// attribution instead of suppressed — see `claude_chunk_parent_tool_use_id`. +/// Suppression is the FALLBACK, not the contract: a chunk the wire LINKED to +/// its owning Agent call is routed instead (forwarded with that attribution, +/// and the frontend files it into the live Agent capsule) — the same contract +/// Claude's parented chunks follow, so sub-agent thinking/text survives into +/// the settled card instead of vanishing with the suppressed stream. Only +/// chunks the wire did not link fall through to suppression below (see +/// `subagent_chunk_parent_tool_use_id` for the two wire shapes). /// /// Suppress while we're inside an open sub-agent window OR when the chunk's own /// meta marks it. The window safety rests on a structural invariant: the window @@ -16244,8 +16241,8 @@ fn codebuddy_chunk_marks_subagent( /// the sub-agent's chunks, never main-agent output. BACKGROUND sub-agents (which /// run concurrently and could interleave main-agent output) are deliberately /// excluded from the window by `track_subagent_window`, so `window_open` can -/// never cause a main-agent chunk to be dropped. Gated on CodeBuddy; every other -/// agent always emits. +/// never cause a main-agent chunk to be dropped. Gated on CodeBuddy; every +/// other agent always emits. fn should_suppress_subagent_chunk( agent_type: AgentType, window_open: bool, @@ -16254,21 +16251,43 @@ fn should_suppress_subagent_chunk( if agent_type != AgentType::CodeBuddy { return false; } + if subagent_chunk_parent_tool_use_id(agent_type, chunk_meta).is_some() { + return false; + } window_open || codebuddy_chunk_marks_subagent(agent_type, chunk_meta) } -/// Extract the update-level `_meta.claudeCode.parentToolUseId` of a live -/// text/thought chunk — set by claude-agent-acp ≥0.63 on a subagent's -/// forwarded chunks once the client advertises the `subagent-transcript` -/// capability (see `build_client_capabilities`). The chunk is emitted WITH -/// this attribution (never suppressed): the frontend routes parented chunks -/// into the live Agent capsule instead of the main thread. Gated on -/// ClaudeCode so no other agent's namespaced meta can alias into parented -/// routing; empty strings are treated as absent. -fn claude_chunk_parent_tool_use_id( +/// Extract the update-level parent-tool-call attribution of a live +/// text/thought chunk — the id of the Agent/Task call that owns the subagent +/// the chunk came from. The chunk is emitted WITH this attribution (never +/// suppressed): the frontend routes parented chunks into the live Agent +/// capsule instead of the main thread. +/// +/// Two wire shapes today: +/// * Claude Code nests it under `_meta.claudeCode.parentToolUseId`, set by +/// claude-agent-acp ≥0.63 on a subagent's forwarded chunks once the client +/// advertises the `subagent-transcript` capability (see +/// `build_client_capabilities`). +/// * CodeBuddy writes a FLAT `_meta["codebuddy.ai/parentToolCallId"]` +/// (measured on CodeBuddy 2.164.0: present on every sub-agent thought/message +/// chunk, never on a main-thread one). +/// +/// Only the owning agent's own id space is read, so no other agent's +/// namespaced meta can alias into parented routing; empty strings are treated +/// as absent. +fn subagent_chunk_parent_tool_use_id( agent_type: AgentType, meta: Option<&serde_json::Map>, ) -> Option { + // CodeBuddy's link is a flat single key, not nested under a namespace + // object — read it before the namespaced pair. + if agent_type == AgentType::CodeBuddy { + return meta? + .get("codebuddy.ai/parentToolCallId")? + .as_str() + .filter(|s| !s.is_empty()) + .map(str::to_owned); + } if agent_type != AgentType::ClaudeCode { return None; } @@ -19785,8 +19804,9 @@ async fn emit_conversation_update( // shape — see `pi_take_startup_banner`. No-op for every other agent. let is_pi_startup_banner = pi_take_startup_banner(agent_type, state, &text.text).await; - // Drop a CodeBuddy sub-agent's interleaved message text — it belongs - // to the Agent pill, not the main thread (see + // Drop a CodeBuddy sub-agent's interleaved message text that the + // wire did not link — it belongs to the Agent pill, not the main + // thread; linked chunks are routed into the capsule instead (see // `should_suppress_subagent_chunk`). No-op for every other agent. if !is_pi_startup_banner && !should_suppress_subagent_chunk( @@ -19824,12 +19844,14 @@ async fn emit_conversation_update( .await; } PiChunkRoute::Prose => { - // Claude subagent chunks (claude-agent-acp ≥0.63 with the - // `subagent-transcript` capability) are NOT suppressed: they - // emit with their parent id so the frontend can route them - // into the live Agent capsule. + // Sub-agent chunks the wire LINKED to their Agent call are + // NOT suppressed: they emit with their parent id so the + // frontend can route them into the live Agent capsule + // (claude-agent-acp ≥0.63 nested `claudeCode.parentToolUseId`; + // CodeBuddy flat `codebuddy.ai/parentToolCallId` — both read + // by `subagent_chunk_parent_tool_use_id`). let parent_tool_use_id = - claude_chunk_parent_tool_use_id(agent_type, meta.as_ref()); + subagent_chunk_parent_tool_use_id(agent_type, meta.as_ref()); emit_with_state( state, emitter, @@ -19851,13 +19873,13 @@ async fn emit_conversation_update( meta, .. }) => { - // Same suppression for a sub-agent's interleaved reasoning. + // Same suppression/routing for a sub-agent's interleaved reasoning. if !should_suppress_subagent_chunk( agent_type, !cb_state.open_subagents.is_empty(), meta.as_ref(), ) { - let parent_tool_use_id = claude_chunk_parent_tool_use_id(agent_type, meta.as_ref()); + let parent_tool_use_id = subagent_chunk_parent_tool_use_id(agent_type, meta.as_ref()); emit_with_state( state, emitter, @@ -33878,52 +33900,238 @@ mod tests { false, None )); - // Window closed but the chunk's own meta marks it → suppress (precision - // supplement; never relied on alone). + // Window closed, meta marks it but carries NO parent link → suppress + // (the fallback path; suppression is never relied on alone). let sub = serde_json::json!({ "codebuddy.ai/isSubagent": true }); + assert!(should_suppress_subagent_chunk( + AgentType::CodeBuddy, + false, + sub.as_object() + )); + // Window closed and the chunk carries its own parent link → ROUTED, not + // suppressed: it emits with that attribution and the frontend files it + // into the live Agent capsule, so sub-agent thinking/text survives into + // the settled card instead of vanishing. let parented = serde_json::json!({ "codebuddy.ai/parentToolCallId": "call_x" }); - for meta in [&sub, &parented] { - assert!(should_suppress_subagent_chunk( - AgentType::CodeBuddy, - false, - meta.as_object() - )); - } + assert!(!should_suppress_subagent_chunk( + AgentType::CodeBuddy, + false, + parented.as_object() + )); + // Even inside an open window a linked chunk is routed — suppression + // only ever catches chunks the wire did not link. + assert!(!should_suppress_subagent_chunk( + AgentType::CodeBuddy, + true, + parented.as_object() + )); // Other agents never suppress, even inside a (spurious) open window. assert!(!should_suppress_subagent_chunk(AgentType::OpenCode, true, None)); } + #[test] + fn codebuddy_chunk_parent_reads_the_flat_meta_only_for_codebuddy() { + let valid = serde_json::json!({ "codebuddy.ai/parentToolCallId": "chatcmpl-tool-1" }); + assert_eq!( + subagent_chunk_parent_tool_use_id(AgentType::CodeBuddy, valid.as_object()), + Some("chatcmpl-tool-1".to_string()) + ); + // The same flat key on another agent must not alias into routing. + assert_eq!( + subagent_chunk_parent_tool_use_id(AgentType::ClaudeCode, valid.as_object()), + None + ); + // Empty / wrong type / absent key / absent meta → None. + let empty = serde_json::json!({ "codebuddy.ai/parentToolCallId": "" }); + assert_eq!( + subagent_chunk_parent_tool_use_id(AgentType::CodeBuddy, empty.as_object()), + None + ); + let wrong_type = serde_json::json!({ "codebuddy.ai/parentToolCallId": 42 }); + assert_eq!( + subagent_chunk_parent_tool_use_id(AgentType::CodeBuddy, wrong_type.as_object()), + None + ); + let no_key = serde_json::json!({ "codebuddy.ai/isSubagent": true }); + assert_eq!( + subagent_chunk_parent_tool_use_id(AgentType::CodeBuddy, no_key.as_object()), + None + ); + assert_eq!(subagent_chunk_parent_tool_use_id(AgentType::CodeBuddy, None), None); + } + + /// The quirk gate is the exact built-in registration, never an id prefix: + /// a custom registration whose id merely starts with "codebuddy" gets + /// none of the built-in CodeBuddy quirk handling. Pins the review + /// decision that removed the prefix-based `is_codebuddy_family`. + #[test] + fn custom_codebuddy_registrations_get_no_builtin_quirks() { + let custom = AgentType::Custom("codebuddy-cn"); + let meta = serde_json::json!({ + "codebuddy.ai/toolName": "Agent", + "codebuddy.ai/isSubagent": true, + "codebuddy.ai/parentToolCallId": "chatcmpl-tool-1", + }); + assert!(!codebuddy_meta_marks_subagent(custom, meta.as_object())); + assert!(!should_suppress_subagent_chunk( + custom, + true, + meta.as_object() + )); + assert_eq!( + subagent_chunk_parent_tool_use_id(custom, meta.as_object()), + None + ); + let raw = Some("{\"toolName\":\"x\"}".to_string()); + assert_eq!(codebuddy_deferred_tool_name(custom, &raw), None); + assert_eq!(unwrap_codebuddy_deferred_output(custom, "\"type\""), None); + } + + /// Drive `emit_conversation_update` over several updates sharing one + /// `CodeBuddyLiveState`, like `pi_emit_chunk` does for pi's banner — + /// the suppression window only exists across updates, so a CodeBuddy + /// test needs the shared-state form. + async fn cb_emit_all(agent_type: AgentType, wires: &[serde_json::Value]) -> Vec { + let st = SessionState::new( + "conn-cb".to_string(), + agent_type, + None, + "win".to_string(), + None, + ); + let state = Arc::new(RwLock::new(st)); + let emitter = EventEmitter::Noop; + let mut cache = ToolCallOutputCache::default(); + let mut cb = CodeBuddyLiveState::default(); + for wire in wires { + let update: SessionUpdate = + serde_json::from_value(wire.clone()).expect("valid CodeBuddy wire shape"); + emit_conversation_update( + &state, &emitter, agent_type, update, None, &mut cache, &mut cb, + ) + .await; + } + + let guard = state.read().await; + guard + .recent_events_after(0) + .map(|events| events.iter().map(|e| e.payload.clone()).collect()) + .unwrap_or_default() + } + + /// The live contract end to end: while an Agent call's window is open, a + /// chunk LINKED to it is routed (emitted with its parent id) and an + /// unlinked chunk is suppressed; after the Agent call completes, the main + /// agent's chunk is emitted unparented. + #[tokio::test] + async fn codebuddy_linked_chunk_routes_through_emit_and_unlinked_is_suppressed() { + let agent_call = serde_json::json!({ + "sessionUpdate": "tool_call", + "toolCallId": "call-agent", + "title": "Agent", + "kind": "other", + "status": "in_progress", + "rawInput": {"description": "explore", "prompt": "map it"}, + "_meta": {"codebuddy.ai/toolName": "Agent", "codebuddy.ai/isSubagent": true} + }); + let linked_thought = serde_json::json!({ + "sessionUpdate": "agent_thought_chunk", + "content": {"type": "text", "text": "SUB thinks"}, + "_meta": {"codebuddy.ai/parentToolCallId": "call-agent"} + }); + let unlinked_thought = serde_json::json!({ + "sessionUpdate": "agent_thought_chunk", + "content": {"type": "text", "text": "SUB unlinked"} + }); + let agent_done = serde_json::json!({ + "sessionUpdate": "tool_call_update", + "toolCallId": "call-agent", + "status": "completed" + }); + let main_chunk = serde_json::json!({ + "sessionUpdate": "agent_message_chunk", + "content": {"type": "text", "text": "MAIN summary"} + }); + + let events = cb_emit_all( + AgentType::CodeBuddy, + &[ + agent_call, + linked_thought, + unlinked_thought, + agent_done, + main_chunk, + ], + ) + .await; + + // The linked thought is routed with its parent id. + let routed = events + .iter() + .find_map(|e| match e { + AcpEvent::Thinking { + text, + parent_tool_use_id, + } => Some((text.clone(), parent_tool_use_id.clone())), + _ => None, + }) + .expect("linked sub-agent thought is routed, not suppressed"); + assert_eq!(routed.0, "SUB thinks"); + assert_eq!(routed.1.as_deref(), Some("call-agent")); + + // The unlinked thought inside the open window never reaches the stream. + assert!(events.iter().all(|e| match e { + AcpEvent::Thinking { text, .. } => text != "SUB unlinked", + _ => true, + })); + + // After the Agent call completes, the main agent's prose is emitted + // unparented. + let main = events + .iter() + .find_map(|e| match e { + AcpEvent::ContentDelta { + text, + parent_tool_use_id, + } => Some((text.clone(), parent_tool_use_id.clone())), + _ => None, + }) + .expect("main-agent prose after the window closes"); + assert_eq!(main.0, "MAIN summary"); + assert_eq!(main.1, None); + } + #[test] fn claude_chunk_parent_reads_only_wellformed_claude_meta() { let valid = serde_json::json!({ "claudeCode": { "parentToolUseId": "toolu_01A" } }); assert_eq!( - claude_chunk_parent_tool_use_id(AgentType::ClaudeCode, valid.as_object()), + subagent_chunk_parent_tool_use_id(AgentType::ClaudeCode, valid.as_object()), Some("toolu_01A".to_string()) ); // Gated on ClaudeCode — the same meta on another agent must not alias // into parented routing. assert_eq!( - claude_chunk_parent_tool_use_id(AgentType::CodeBuddy, valid.as_object()), + subagent_chunk_parent_tool_use_id(AgentType::CodeBuddy, valid.as_object()), None ); // Absent meta / absent key / wrong type / empty string → None. assert_eq!( - claude_chunk_parent_tool_use_id(AgentType::ClaudeCode, None), + subagent_chunk_parent_tool_use_id(AgentType::ClaudeCode, None), None ); let no_key = serde_json::json!({ "claudeCode": { "toolName": "Agent" } }); assert_eq!( - claude_chunk_parent_tool_use_id(AgentType::ClaudeCode, no_key.as_object()), + subagent_chunk_parent_tool_use_id(AgentType::ClaudeCode, no_key.as_object()), None ); let wrong_type = serde_json::json!({ "claudeCode": { "parentToolUseId": 42 } }); assert_eq!( - claude_chunk_parent_tool_use_id(AgentType::ClaudeCode, wrong_type.as_object()), + subagent_chunk_parent_tool_use_id(AgentType::ClaudeCode, wrong_type.as_object()), None ); let empty = serde_json::json!({ "claudeCode": { "parentToolUseId": "" } }); assert_eq!( - claude_chunk_parent_tool_use_id(AgentType::ClaudeCode, empty.as_object()), + subagent_chunk_parent_tool_use_id(AgentType::ClaudeCode, empty.as_object()), None ); } diff --git a/src-tauri/src/parsers/claude.rs b/src-tauri/src/parsers/claude.rs index a7bc76d305..c44c7830fb 100644 --- a/src-tauri/src/parsers/claude.rs +++ b/src-tauri/src/parsers/claude.rs @@ -9,7 +9,7 @@ use regex::Regex; use crate::models::*; use crate::parsers::{ folder_name_from_path, is_safe_subagent_id, title_from_user_text, truncate_str, AgentEnv, - AgentParser, ParseError, + AgentParser, ParseError, TIMELINE_ENTRY_MAX_CHARS, }; /// Regex that matches Claude Code system-injected XML tags and their content. @@ -3465,14 +3465,6 @@ fn attribute_subagent_usage(session_path: &Path, turns: &mut [MessageTurn]) { } } -/// Per-entry cap for thinking/text blocks folded into a sub-agent timeline, so -/// one huge block cannot balloon the stats every detail load carries. Almost -/// every block is far below it; the ones that reach it are typically the -/// child's long final report (measured on local Claude Code transcripts: 27 of -/// 7,715 blocks, the longest ~40K chars), which the card also shows in full as -/// the Agent call's own result. -const TIMELINE_ENTRY_MAX_CHARS: usize = 16_384; - /// One parsed sub-agent transcript: the tool calls, the mixed timeline, /// the deduped usage, the first record's timestamp, and the /// first-to-last-timestamped-record span (`None` without a real interval). diff --git a/src-tauri/src/parsers/codebuddy.rs b/src-tauri/src/parsers/codebuddy.rs index 47e534796a..656c665b0e 100644 --- a/src-tauri/src/parsers/codebuddy.rs +++ b/src-tauri/src/parsers/codebuddy.rs @@ -8,8 +8,9 @@ use serde_json::Value; use walkdir::WalkDir; use crate::models::{ - AgentExecutionStats, AgentToolCall, AgentType, ContentBlock, ConversationDetail, - ConversationSummary, MessageRole, MessageTurn, TurnRole, TurnUsage, UnifiedMessage, + AgentExecutionStats, AgentTimelineEntry, AgentToolCall, AgentType, ContentBlock, + ConversationDetail, ConversationSummary, MessageRole, MessageTurn, TurnRole, TurnUsage, + UnifiedMessage, }; use crate::parsers::claude::{ capture_tag, task_notification_status_regex, task_notification_summary_regex, @@ -20,7 +21,7 @@ use crate::parsers::{ infer_context_window_max_tokens, is_safe_subagent_id, latest_turn_total_usage_tokens, merge_context_window_stats, relocate_orphaned_tool_results, resolve_patch_line_numbers, structurize_read_tool_output, title_from_user_text, truncate_str, AgentEnv, AgentParser, - ParseError, + ParseError, TIMELINE_ENTRY_MAX_CHARS, }; /// Resolve CodeBuddy's config dir, honoring `CODEBUDDY_CONFIG_DIR`, else @@ -1013,16 +1014,29 @@ fn apply_background_lifecycle( } } -/// Parse a CodeBuddy sub-agent transcript and extract its tool calls. +/// Parse a CodeBuddy sub-agent transcript into its tool calls and its mixed +/// activity timeline. /// /// The sub-agent JSONL uses the same OpenAI-items schema as the main session -/// (`function_call` / `function_call_result` records); we pair them by `callId` -/// and reuse the outer parser's name/preview/error helpers so nested calls -/// render identically to top-level ones. Mirrors `claude.rs`'s +/// (`function_call` / `function_call_result` records); we pair calls and +/// results by `callId` and reuse the outer parser's name/preview/error helpers +/// so nested calls render identically to top-level ones. The timeline is the +/// card's historical activity view: the child's `reasoning` records and +/// assistant `message` records interleave with its tool calls exactly as the +/// model emitted them, and that order is preserved — each `tool_call` entry's +/// `child_index` anchors into the returned vec by ordinal, which stays exact +/// because the vec keeps arrival order. Mirrors `claude.rs`'s /// `parse_subagent_tool_calls`, which does the same for Claude's schema. -fn parse_codebuddy_subagent_tool_calls(path: &Path) -> Vec { +/// +/// Only the sub-agent's own records enter the timeline: the transcript opens +/// with the dispatching user prompt (`role: "user"` — measured on a real +/// transcript) and roleless or malformed lines are ignored, so a child that +/// ran no tools still yields its prose without leaking the prompt. +fn parse_codebuddy_subagent_transcript( + path: &Path, +) -> (Vec, Vec) { let Ok(file) = fs::File::open(path) else { - return Vec::new(); + return (Vec::new(), Vec::new()); }; let reader = BufReader::new(file); @@ -1030,6 +1044,7 @@ fn parse_codebuddy_subagent_tool_calls(path: &Path) -> Vec { let mut calls: Vec<(Option, String, Option)> = Vec::new(); let mut results: std::collections::HashMap, bool)> = std::collections::HashMap::new(); + let mut timeline: Vec = Vec::new(); for line in reader.lines() { let Ok(line) = line else { continue }; @@ -1046,6 +1061,11 @@ fn parse_codebuddy_subagent_tool_calls(path: &Path) -> Vec { resolve_tool_call_name(&value), tool_input_preview(&value).map(|s| truncate_str(&s, 500)), )); + timeline.push(AgentTimelineEntry { + entry_type: "tool_call".to_string(), + text: None, + child_index: Some(calls.len() - 1), + }); } "function_call_result" => { if let Some(id) = call_id(&value) { @@ -1053,11 +1073,40 @@ fn parse_codebuddy_subagent_tool_calls(path: &Path) -> Vec { results.insert(id, (output, tool_is_error(&value))); } } + // The sub-agent's own reasoning, interleaved with its calls. The + // text lives in `rawContent[].text` (`reasoning_text` items) — + // some records mirror it under `content[]`, which + // `reasoning_text` falls back to. + "reasoning" => { + let text = reasoning_text(&value); + if !text.trim().is_empty() { + timeline.push(AgentTimelineEntry { + entry_type: "thinking".to_string(), + text: Some(truncate_str(&text, TIMELINE_ENTRY_MAX_CHARS)), + child_index: None, + }); + } + } + // The sub-agent's narration. The transcript's opening `message` + // is the dispatching user prompt, excluded by the role guard. + "message" => { + if value.get("role").and_then(|r| r.as_str()) != Some("assistant") { + continue; + } + let text = collect_text(&value, "output_text"); + if !text.trim().is_empty() { + timeline.push(AgentTimelineEntry { + entry_type: "text".to_string(), + text: Some(truncate_str(&text, TIMELINE_ENTRY_MAX_CHARS)), + child_index: None, + }); + } + } _ => {} } } - calls + let tool_calls = calls .into_iter() .map(|(id, tool_name, input_preview)| { let (output_preview, is_error) = @@ -1069,21 +1118,29 @@ fn parse_codebuddy_subagent_tool_calls(path: &Path) -> Vec { is_error, } }) - .collect() + .collect(); + + (tool_calls, timeline) } /// Build `agent_stats` for an `Agent` `function_call_result` by loading the -/// sub-agent's own transcript and extracting its nested tool calls — the -/// historical mirror of the live path, which synthesizes the same `agent_stats` -/// from the streamed child tool calls (`conversation-runtime-context.tsx`). +/// sub-agent's own transcript and extracting its nested tool calls and its +/// thinking/text timeline — the historical mirror of the live path, which +/// routes the linked chunks into the Agent capsule as they stream (see +/// `subagent_chunk_parent_tool_use_id`). With both halves populated, the +/// settled card shows the child's activity in the same order after a reopen +/// as it did live; the live view may merge successive same-kind chunks into +/// one entry where the parser writes one entry per transcript record. /// /// Returns `None` for ordinary results (no sub-agent linkage — see -/// [`subagent_transcript_id`]), a missing/empty transcript, or a sub-agent that -/// ran no tools, so the common case stays a plain tool result. A still-running -/// async worker also lands here (its transcript has no completed calls yet) and -/// recovers on the next refetch. `main_session_path` is the real `.jsonl` path -/// the parser is reading; the transcript sits beside it under -/// `/subagents/`. +/// [`subagent_transcript_id`]) or a transcript carrying neither calls nor +/// assistant prose (missing, or only the dispatching prompt / bad lines — +/// e.g. a worker that has written nothing yet), so those stay plain tool +/// results. A still-running async worker's transcript may already carry prose +/// and calls, so its card shows the activity so far and recovers the rest on +/// the next refetch — the same semantics Claude's parser gives its +/// sub-agents. `main_session_path` is the real `.jsonl` path the parser is +/// reading; the transcript sits beside it under `/subagents/`. fn agent_stats_from_subagent( result: &Value, main_session_path: &Path, @@ -1102,8 +1159,8 @@ fn agent_stats_from_subagent( if !transcript.exists() { return None; } - let tool_calls = parse_codebuddy_subagent_tool_calls(&transcript); - if tool_calls.is_empty() { + let (tool_calls, timeline) = parse_codebuddy_subagent_transcript(&transcript); + if tool_calls.is_empty() && timeline.is_empty() { return None; } let tool_count = tool_calls.len() as u32; @@ -1121,7 +1178,7 @@ fn agent_stats_from_subagent( lines_removed: None, other_tool_count: None, tool_calls, - timeline: Vec::new(), + timeline, recovered_result: None, // CodeBuddy's sub-agent transcript is a file under the parent session, // not a session of its own. @@ -1273,6 +1330,218 @@ mod tests { } } + fn write_subagent_transcript(root: &Path, session_id: &str, agent_id: &str, records: &[Value]) { + let dir = root.join(session_id).join("subagents"); + std::fs::create_dir_all(&dir).expect("create subagents dir"); + let mut file = + std::fs::File::create(dir.join(format!("{agent_id}.jsonl"))).expect("create jsonl"); + for record in records { + writeln!( + file, + "{}", + serde_json::to_string(record).expect("serialize") + ) + .expect("write line"); + } + } + + fn agent_result(agent_id: &str) -> Value { + serde_json::json!({ + "providerData": {"toolResult": {"subAgent": {"sessionId": agent_id}}} + }) + } + + fn subagent_stats( + root: &Path, + session_id: &str, + agent_id: &str, + ) -> Option { + agent_stats_from_subagent( + &agent_result(agent_id), + &root.join(format!("{session_id}.jsonl")), + ) + } + + fn user_prompt(text: &str) -> Value { + serde_json::json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": text}] + }) + } + + fn reasoning(raw: &str) -> Value { + // `content` deliberately absent: the text lives in `rawContent[].text`. + serde_json::json!({ + "type": "reasoning", + "rawContent": [{"type": "reasoning_text", "text": raw}] + }) + } + + fn function_call(call_id: &str, name: &str, args: &str) -> Value { + serde_json::json!({ + "type": "function_call", + "callId": call_id, + "name": name, + "arguments": args + }) + } + + fn function_call_result(call_id: &str, output: &str) -> Value { + serde_json::json!({ + "type": "function_call_result", + "callId": call_id, + "output": output + }) + } + + fn assistant_message(text: &str) -> Value { + serde_json::json!({ + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": text}] + }) + } + + /// The timeline is the card's historical activity view: the child's + /// reasoning and narration interleave with its tool calls in transcript + /// order, each `tool_call` entry anchoring into `tool_calls` by ordinal. + #[test] + fn subagent_timeline_orders_thinking_text_and_tool_calls() { + let root = std::env::temp_dir().join(format!("codeg-cb-tl-{}", uuid::Uuid::new_v4())); + write_subagent_transcript( + &root, + "sess", + "agent-t1", + &[ + user_prompt("DISPATCH do the job"), + reasoning("PLAN ONE"), + function_call("call_1", "Read", r#"{"path":"src/main.rs"}"#), + function_call_result("call_1", "file body"), + assistant_message("mid report"), + function_call("call_2", "Write", r#"{"path":"out.txt"}"#), + function_call_result("call_2", "ok"), + assistant_message("FINAL report"), + ], + ); + let stats = subagent_stats(&root, "sess", "agent-t1").expect("stats for a busy subagent"); + + assert_eq!(stats.tool_calls.len(), 2); + assert_eq!(stats.tool_calls[0].tool_name, "Read"); + assert_eq!(stats.tool_calls[1].tool_name, "Write"); + assert_eq!(stats.total_tool_use_count, Some(2)); + + let kinds: Vec<&str> = stats + .timeline + .iter() + .map(|e| e.entry_type.as_str()) + .collect(); + assert_eq!( + kinds, + ["thinking", "tool_call", "text", "tool_call", "text"] + ); + let texts: Vec<&str> = stats + .timeline + .iter() + .filter_map(|e| e.text.as_deref()) + .collect(); + assert_eq!(texts, ["PLAN ONE", "mid report", "FINAL report"]); + let indexes: Vec = stats + .timeline + .iter() + .filter_map(|e| e.child_index) + .collect(); + assert_eq!(indexes, [0, 1]); + + // The dispatching prompt is the transcript's first line and must never + // surface in the card; a roleless message is ignored the same way. + assert!(!texts.iter().any(|t| t.contains("DISPATCH"))); + } + + /// A roleless `message` (and anything that is not the child's own + /// assistant prose) stays out of the timeline. + #[test] + fn subagent_user_prompt_and_roleless_messages_never_enter_the_timeline() { + let root = std::env::temp_dir().join(format!("codeg-cb-up-{}", uuid::Uuid::new_v4())); + write_subagent_transcript( + &root, + "sess", + "agent-up", + &[ + user_prompt("DISPATCH secret"), + serde_json::json!({ + "type": "message", + "content": [{"type": "output_text", "text": "ROLELESS"}] + }), + assistant_message("own prose"), + ], + ); + let stats = subagent_stats(&root, "sess", "agent-up").expect("text-only subagent"); + let texts: Vec<&str> = stats + .timeline + .iter() + .filter_map(|e| e.text.as_deref()) + .collect(); + assert_eq!(texts, ["own prose"]); + } + + /// Reasoning text lives in `rawContent[].text` and is capped per entry. + #[test] + fn subagent_thinking_reads_rawcontent_and_truncates() { + let long = "x".repeat(TIMELINE_ENTRY_MAX_CHARS + 10); + let root = std::env::temp_dir().join(format!("codeg-cb-tr-{}", uuid::Uuid::new_v4())); + write_subagent_transcript( + &root, + "sess", + "agent-tr", + &[reasoning(&long), reasoning("short thought")], + ); + let stats = subagent_stats(&root, "sess", "agent-tr").expect("reasoning-only subagent"); + assert_eq!(stats.timeline.len(), 2); + let truncated = stats.timeline[0].text.as_deref().expect("truncated text"); + assert_eq!(truncated.chars().count(), TIMELINE_ENTRY_MAX_CHARS + 3); + assert!(truncated.starts_with("xxx")); + assert!(truncated.ends_with("...")); + assert_eq!(stats.timeline[1].text.as_deref(), Some("short thought")); + } + + /// A child that wrote prose but ran no tools still gets `agent_stats` — + /// with empty `tool_calls` and a prose timeline — instead of collapsing + /// into a plain tool result. + #[test] + fn subagent_text_only_still_gets_stats() { + let root = std::env::temp_dir().join(format!("codeg-cb-to-{}", uuid::Uuid::new_v4())); + write_subagent_transcript( + &root, + "sess", + "agent-to", + &[ + user_prompt("DISPATCH"), + assistant_message("answer without tools"), + ], + ); + let stats = subagent_stats(&root, "sess", "agent-to").expect("text-only subagent"); + assert!(stats.tool_calls.is_empty()); + assert_eq!(stats.total_tool_use_count, Some(0)); + assert_eq!(stats.timeline.len(), 1); + assert_eq!(stats.timeline[0].entry_type, "text"); + assert_eq!( + stats.timeline[0].text.as_deref(), + Some("answer without tools") + ); + } + + /// A transcript with neither calls nor assistant prose — missing, or only + /// the dispatching prompt / bad lines — stays a plain tool result. + #[test] + fn subagent_without_prose_or_calls_is_no_stats() { + let root = std::env::temp_dir().join(format!("codeg-cb-empty-{}", uuid::Uuid::new_v4())); + write_subagent_transcript(&root, "sess", "agent-empty", &[user_prompt("DISPATCH")]); + assert!(subagent_stats(&root, "sess", "agent-empty").is_none()); + // A missing transcript is equally `None`. + assert!(subagent_stats(&root, "sess", "agent-missing").is_none()); + } + #[test] fn parses_item_format_text_session() { let root = std::env::temp_dir().join(format!("codeg-cb-text-{}", uuid::Uuid::new_v4())); diff --git a/src-tauri/src/parsers/kimi_code.rs b/src-tauri/src/parsers/kimi_code.rs index 4ccd10d71d..d1ad3b6c42 100644 --- a/src-tauri/src/parsers/kimi_code.rs +++ b/src-tauri/src/parsers/kimi_code.rs @@ -959,7 +959,7 @@ fn subagent_id_from_output(output: &str) -> Option<&str> { /// `tool.call` with its `tool.result` by `toolCallId`. The outer /// `tool_args_preview` / `tool_result_preview` helpers are reused so nested calls /// render identically to top-level ones. Mirrors `codebuddy.rs`'s -/// `parse_codebuddy_subagent_tool_calls`. +/// `parse_codebuddy_subagent_transcript`. /// /// Intentionally non-recursive: a nested `Agent` call inside the sub-agent shows /// as a flat leaf tool here (no further descent), which bounds the work and diff --git a/src-tauri/src/parsers/mod.rs b/src-tauri/src/parsers/mod.rs index 6ca0dd6695..e66de9b033 100644 --- a/src-tauri/src/parsers/mod.rs +++ b/src-tauri/src/parsers/mod.rs @@ -580,6 +580,14 @@ pub fn truncate_str(s: &str, max_len: usize) -> String { } } +/// Per-entry cap for thinking/text blocks folded into a sub-agent timeline, so +/// one huge block cannot balloon the stats every detail load carries. Almost +/// every block is far below it; the ones that reach it are typically the +/// child's long final report (measured on local Claude Code transcripts: 27 of +/// 7,715 blocks, the longest ~40K chars), which the card also shows in full as +/// the Agent call's own result. +pub(crate) const TIMELINE_ENTRY_MAX_CHARS: usize = 16_384; + /// True when `id` is safe to embed as a single filename component beneath a /// session's `subagents/` directory (Claude Code's and CodeBuddy's sub-agent /// transcript layout). The id is read straight from transcript JSON diff --git a/src/contexts/acp-connections-context.tsx b/src/contexts/acp-connections-context.tsx index 9f55788e1a..651c5b8d02 100644 --- a/src/contexts/acp-connections-context.tsx +++ b/src/contexts/acp-connections-context.tsx @@ -227,10 +227,12 @@ export interface ClaudeApiRetryState { export type LiveContentBlock = /** - * `parentToolUseId`: subagent attribution (claude-agent-acp ≥0.63, - * `_meta.claudeCode.parentToolUseId`). Parented text/thinking belongs to - * the live Agent capsule of that tool call — the runtime store routes it - * out of the main thread. `undefined` = main-thread content. + * `parentToolUseId`: subagent attribution — Claude Code nests it at + * `_meta.claudeCode.parentToolUseId` (claude-agent-acp ≥0.63), CodeBuddy + * sends it flat at `_meta["codebuddy.ai/parentToolCallId"]`. Parented + * text/thinking belongs to the live Agent capsule of that tool call — the + * runtime store routes it out of the main thread. `undefined` = main-thread + * content. */ | { type: "text"; text: string; parentToolUseId?: string } | { type: "thinking"; text: string; parentToolUseId?: string } diff --git a/src/lib/types.ts b/src/lib/types.ts index 61def4d7f9..489356cd92 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -263,9 +263,10 @@ export type ContentBlock = images?: ImageData[] | null /** * LIVE-stream data: the in-flight transcript of a native subagent — - * a marker per child tool call, plus (Claude Code only) the text/ - * thinking chunks attributed to this Agent tool call via - * `_meta.claudeCode.parentToolUseId` (claude-agent-acp ≥0.63) — + * a marker per child tool call, plus the text/thinking chunks + * attributed to this Agent tool call — Claude Code via nested + * `_meta.claudeCode.parentToolUseId` (claude-agent-acp ≥0.63), + * CodeBuddy via flat `_meta["codebuddy.ai/parentToolCallId"]` — * rendered inside the live Agent capsule. Still attached at settle so * the finished card keeps the interleaved order it showed live; once * history reloads from disk, the parsed `agent_stats.timeline` takes diff --git a/src/stores/conversation-runtime-store.ts b/src/stores/conversation-runtime-store.ts index 49e0c33517..8ea560b72c 100644 --- a/src/stores/conversation-runtime-store.ts +++ b/src/stores/conversation-runtime-store.ts @@ -2359,10 +2359,12 @@ function reducer( // Agent transcripts ride along on promotion: the settled card keeps the // interleaved order it showed live, and the authoritative detail reload // that later replaces `localTurns` supplies `agent_stats.timeline` - // parsed from the child's own transcript (Claude Code, Qoder). The only - // live prose comes from Claude Code; elsewhere the transcript is tool - // markers alone, which the card renders like stats, so a reload from - // an agent without a parsed timeline does not change the card's shape. + // parsed from the child's own transcript (Claude Code, Qoder, CodeBuddy). + // Live sub-agent prose arrives from Claude Code's forwarded chunks and + // from CodeBuddy's parented chunks (routed by the backend into the live + // Agent capsule); agents without a parsed timeline still render their + // transcript as tool markers alone, so a reload does not change the + // card's shape. const streamingTurns = sourceLiveMessage ? buildStreamingTurnsFromLiveMessage( current.conversationId,