From 5fe2fbbbbc921fa2dfa49a12e58230a50952b2de Mon Sep 17 00:00:00 2001 From: Liang-HZ <36034777+Liang-HZ@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:09:58 +0800 Subject: [PATCH 1/2] feat(codebuddy): apply the quirk handling to the whole family, route linked sub-agent chunks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A custom registration that IS the CodeBuddy CLI (multi-account or env-isolated setups — `codebuddy`, `codebuddy-*` in the registry) streams exactly the shapes the built-in agent does, but every CodeBuddy quirk was gated on `AgentType::CodeBuddy` alone: its sub-agent thought/message chunks leaked into the main stream (each also rendered inside the Agent pill's window, out of order), and it missed the Agent-pill title rewrite and the deferred-MCP unwraps. `is_codebuddy_family` (the built-in, or a `codebuddy` / `codebuddy-*` custom id) now gates all eight sites; the id-prefix scope keeps every quirk on agents that ARE the CodeBuddy CLI, so no unrelated agent's `codebuddy.ai/*` keys can alias in. Suppression of sub-agent chunks becomes the FALLBACK. Measured on CodeBuddy 2.164.0: every sub-agent thought/message chunk carries a flat `_meta["codebuddy.ai/parentToolCallId"]` (main-thread chunks never do), so a linked chunk is routed exactly like Claude's parented chunks — forwarded with its attribution, and the frontend files it into the live Agent capsule, so the sub-agent's thinking/text survives into the settled card instead of vanishing with the suppressed stream. The attribution reader is renamed to `subagent_chunk_parent_tool_use_id` (two wire shapes today: Claude's nested `_meta.claudeCode.parentToolUseId` and the family's flat key), and each family's own id space stays separate so neither can alias the other. Tests: the family scope, the custom variant's suppression + meta classification path, and the flat-key reader (family-only, empty/wrong-type absent). --- src-tauri/src/acp/connection.rs | 231 ++++++++++++++++++++++++++------ 1 file changed, 187 insertions(+), 44 deletions(-) diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 54897c758f..7c162d5ac2 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -4726,7 +4726,7 @@ 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 @@ -13771,6 +13771,28 @@ fn cursor_companion_title_from_content(content: Option<&str>) -> Option<&'static None } +/// Whether this live agent belongs to the CodeBuddy family and therefore gets +/// the CodeBuddy quirk handling: the built-in CodeBuddy agent, or a custom agent +/// registered as a CodeBuddy CLI variant (registry id `codebuddy` or +/// `codebuddy-*` — e.g. multi-account or env-isolated setups run the same CLI +/// binary and stream identical `codebuddy.ai/*` `_meta` shapes). +/// +/// Every CodeBuddy quirk is gated on this predicate rather than on +/// `AgentType::CodeBuddy` alone: a custom registration that IS the CodeBuddy CLI +/// needs the same handling — without it, its sub-agent thought/message chunks +/// leaked into the main stream (each also rendered inside the Agent pill's +/// window, out of order) and it missed the Agent-pill title rewrite and +/// deferred-MCP unwraps. The id-prefix scope keeps every quirk on agents that +/// ARE the CodeBuddy CLI, so no unrelated agent's `codebuddy.ai/*` keys can +/// alias in. +fn is_codebuddy_family(agent_type: AgentType) -> bool { + match agent_type { + AgentType::CodeBuddy => true, + AgentType::Custom(id) => id == "codebuddy" || id.starts_with("codebuddy-"), + _ => false, + } +} + /// Mirrors `parsers/opencode.rs:425-429` (and `parsers/codebuddy.rs`'s /// `subagent_type → "Agent"` rewrite) so streaming and reload-from-DB render the /// same Agent card. The SQLite-side condition is @@ -13785,7 +13807,7 @@ fn cursor_companion_title_from_content(content: Option<&str>) -> Option<&'static /// identify a sub-agent invocation in practice. Other agents stay excluded to /// avoid any cross-agent collision a generic `subagent_type` field could cause. fn is_subagent_invocation(agent_type: AgentType, raw_input: &Option) -> bool { - if !matches!(agent_type, AgentType::OpenCode | AgentType::CodeBuddy) { + if !matches!(agent_type, AgentType::OpenCode) && !is_codebuddy_family(agent_type) { return false; } let Some(text) = raw_input.as_deref() else { @@ -13815,7 +13837,7 @@ fn is_subagent_invocation(agent_type: AgentType, raw_input: &Option) -> /// (the cards peel `params` themselves, and that keeps `inferFromInput` from /// misclassifying `cancel_delegation`'s `{task_id}` as a generic task). fn codebuddy_deferred_tool_name(agent_type: AgentType, raw_input: &Option) -> Option { - if agent_type != AgentType::CodeBuddy { + if !is_codebuddy_family(agent_type) { return None; } let text = raw_input.as_deref()?; @@ -13844,7 +13866,7 @@ fn codebuddy_deferred_tool_name(agent_type: AgentType, raw_input: &Option Option { - if agent_type != AgentType::CodeBuddy { + if !is_codebuddy_family(agent_type) { return None; } // Cheap substring guard before parsing a potentially large payload. @@ -13873,7 +13895,7 @@ fn codebuddy_meta_marks_subagent( agent_type: AgentType, meta: Option<&serde_json::Map>, ) -> bool { - if agent_type != AgentType::CodeBuddy { + if !is_codebuddy_family(agent_type) { return false; } let Some(meta) = meta else { @@ -14950,7 +14972,7 @@ fn codebuddy_meta_marks_background( agent_type: AgentType, meta: Option<&serde_json::Map>, ) -> bool { - if agent_type != AgentType::CodeBuddy { + if !is_codebuddy_family(agent_type) { return false; } meta.and_then(|m| m.get("codebuddy.ai/isBackground")) @@ -14968,7 +14990,7 @@ fn codebuddy_chunk_marks_subagent( agent_type: AgentType, meta: Option<&serde_json::Map>, ) -> bool { - if agent_type != AgentType::CodeBuddy { + if !is_codebuddy_family(agent_type) { return false; } let Some(meta) = meta else { @@ -14986,10 +15008,13 @@ fn codebuddy_chunk_marks_subagent( /// 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 @@ -14999,31 +15024,53 @@ 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 the CodeBuddy +/// family; every other agent always emits. fn should_suppress_subagent_chunk( agent_type: AgentType, window_open: bool, chunk_meta: Option<&serde_json::Map>, ) -> bool { - if agent_type != AgentType::CodeBuddy { + if !is_codebuddy_family(agent_type) { + 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`). +/// * The CodeBuddy family 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 family'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 is_codebuddy_family(agent_type) { + return meta? + .get("codebuddy.ai/parentToolCallId")? + .as_str() + .filter(|s| !s.is_empty()) + .map(str::to_owned); + } if agent_type != AgentType::ClaudeCode { return None; } @@ -15165,7 +15212,7 @@ fn track_subagent_window( open: &mut HashSet, closed: &mut HashSet, ) { - if agent_type != AgentType::CodeBuddy || !is_agent { + if !is_codebuddy_family(agent_type) || !is_agent { return; } let is_final = matches!(status, Some("completed") | Some("failed")); @@ -17344,8 +17391,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( @@ -17388,7 +17436,7 @@ async fn emit_conversation_update( // emit with their parent id so the frontend can route them // into the live Agent capsule. 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, @@ -17410,13 +17458,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, @@ -30602,6 +30650,49 @@ mod tests { ); } + // ─── is_codebuddy_family (the built-in and custom CLI variants) ────── + + /// A custom registration running the same CLI (e.g. `codebuddy-cn` / + /// `codebuddy-intl` for two accounts) must get the same quirk handling as + /// the built-in; ids outside the family must not. + #[test] + fn codebuddy_family_covers_builtin_and_custom_variants_only() { + assert!(is_codebuddy_family(AgentType::CodeBuddy)); + assert!(is_codebuddy_family(AgentType::Custom("codebuddy"))); + assert!(is_codebuddy_family(AgentType::Custom("codebuddy-cn"))); + assert!(is_codebuddy_family(AgentType::Custom("codebuddy-intl"))); + assert!(!is_codebuddy_family(AgentType::Custom("codebuddyx"))); + assert!(!is_codebuddy_family(AgentType::Custom("qoder-cli"))); + assert!(!is_codebuddy_family(AgentType::ClaudeCode)); + assert!(!is_codebuddy_family(AgentType::OpenCode)); + } + + /// The suppression window is the core of the sub-agent folding the custom + /// agents were missing: an open Agent window must suppress chunks for a + /// custom CodeBuddy variant exactly like the built-in. + #[test] + fn custom_codebuddy_variant_gets_subagent_suppression_and_meta_reads() { + let custom = AgentType::Custom("codebuddy-cn"); + // Window open, chunk meta absent → suppressed (the window invariant). + assert!(should_suppress_subagent_chunk(custom, true, None)); + // Window closed but the chunk's own meta marks a sub-agent → suppressed. + let meta = serde_json::json!({ "codebuddy.ai/isSubagent": true }); + assert!(should_suppress_subagent_chunk(custom, false, meta.as_object())); + // Window closed, no marker → emitted. + assert!(!should_suppress_subagent_chunk(custom, false, None)); + // Meta-based classification and title rewrite work for the variant. + let agent_meta = serde_json::json!({ "codebuddy.ai/toolName": "Agent" }); + assert!(codebuddy_meta_marks_subagent(custom, agent_meta.as_object())); + let mut overrides: HashMap = HashMap::new(); + assert_eq!( + resolve_rewritten_title(custom, &None, "tc1", false, true, &mut overrides).as_deref(), + Some("agent") + ); + // Still scoped: a non-family agent with the same meta is untouched. + assert!(!codebuddy_meta_marks_subagent(AgentType::Qoder, agent_meta.as_object())); + assert!(!should_suppress_subagent_chunk(AgentType::Qoder, true, None)); + } + // ─── codebuddy_meta_marks_subagent ─────────────────────────────────── #[test] @@ -30788,52 +30879,104 @@ 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_the_family() { + let valid = serde_json::json!({ "codebuddy.ai/parentToolCallId": "chatcmpl-tool-1" }); + // The built-in and custom variants all read it. + for agent in [ + AgentType::CodeBuddy, + AgentType::Custom("codebuddy-cn"), + AgentType::Custom("codebuddy-intl"), + ] { + assert_eq!( + subagent_chunk_parent_tool_use_id(agent, valid.as_object()), + Some("chatcmpl-tool-1".to_string()) + ); + } + // The same flat key on a non-family 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); + } + #[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 ); } From 43b768ce50306652be13967fce2d9329e2cc530f Mon Sep 17 00:00:00 2001 From: Liang-HZ <36034777+Liang-HZ@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:43:56 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(codebuddy):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20exact-agent=20gates,=20sub-agent=20history=20timeli?= =?UTF-8?q?ne,=20emit-path=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - drop is_codebuddy_family and every id-prefix gate: the quirk handling keys on AgentType::CodeBuddy exactly, so custom registrations whose id merely starts with "codebuddy" get none of it (pinned by a negative test) - remove the unreachable parent-link branch in codebuddy_chunk_marks_subagent and rewrite its doc around the measured 2.164.0 behavior - build agent_stats.timeline from the sub-agent transcript — thinking and text interleaved with tool markers, role-guarded, truncated per entry — and keep agent_stats for a text-only sub-agent (None only when the transcript has neither calls nor prose) - hoist TIMELINE_ENTRY_MAX_CHARS to parsers/mod.rs - drive emit_conversation_update end to end in a tokio test over several updates sharing one CodeBuddyLiveState: a linked chunk is emitted with its parent, an unlinked one is suppressed, and a main-thread chunk after the Agent call completes carries no parent - sync the store/types/context comments that documented the old shape --- src-tauri/src/acp/connection.rs | 283 ++++++++++++-------- src-tauri/src/parsers/claude.rs | 10 +- src-tauri/src/parsers/codebuddy.rs | 314 +++++++++++++++++++++-- src-tauri/src/parsers/kimi_code.rs | 2 +- src-tauri/src/parsers/mod.rs | 8 + src/contexts/acp-connections-context.tsx | 10 +- src/lib/types.ts | 7 +- src/stores/conversation-runtime-store.ts | 10 +- 8 files changed, 492 insertions(+), 152 deletions(-) diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 7c162d5ac2..abc05a55aa 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -13771,28 +13771,6 @@ fn cursor_companion_title_from_content(content: Option<&str>) -> Option<&'static None } -/// Whether this live agent belongs to the CodeBuddy family and therefore gets -/// the CodeBuddy quirk handling: the built-in CodeBuddy agent, or a custom agent -/// registered as a CodeBuddy CLI variant (registry id `codebuddy` or -/// `codebuddy-*` — e.g. multi-account or env-isolated setups run the same CLI -/// binary and stream identical `codebuddy.ai/*` `_meta` shapes). -/// -/// Every CodeBuddy quirk is gated on this predicate rather than on -/// `AgentType::CodeBuddy` alone: a custom registration that IS the CodeBuddy CLI -/// needs the same handling — without it, its sub-agent thought/message chunks -/// leaked into the main stream (each also rendered inside the Agent pill's -/// window, out of order) and it missed the Agent-pill title rewrite and -/// deferred-MCP unwraps. The id-prefix scope keeps every quirk on agents that -/// ARE the CodeBuddy CLI, so no unrelated agent's `codebuddy.ai/*` keys can -/// alias in. -fn is_codebuddy_family(agent_type: AgentType) -> bool { - match agent_type { - AgentType::CodeBuddy => true, - AgentType::Custom(id) => id == "codebuddy" || id.starts_with("codebuddy-"), - _ => false, - } -} - /// Mirrors `parsers/opencode.rs:425-429` (and `parsers/codebuddy.rs`'s /// `subagent_type → "Agent"` rewrite) so streaming and reload-from-DB render the /// same Agent card. The SQLite-side condition is @@ -13807,7 +13785,7 @@ fn is_codebuddy_family(agent_type: AgentType) -> bool { /// identify a sub-agent invocation in practice. Other agents stay excluded to /// avoid any cross-agent collision a generic `subagent_type` field could cause. fn is_subagent_invocation(agent_type: AgentType, raw_input: &Option) -> bool { - if !matches!(agent_type, AgentType::OpenCode) && !is_codebuddy_family(agent_type) { + if !matches!(agent_type, AgentType::OpenCode | AgentType::CodeBuddy) { return false; } let Some(text) = raw_input.as_deref() else { @@ -13837,7 +13815,7 @@ fn is_subagent_invocation(agent_type: AgentType, raw_input: &Option) -> /// (the cards peel `params` themselves, and that keeps `inferFromInput` from /// misclassifying `cancel_delegation`'s `{task_id}` as a generic task). fn codebuddy_deferred_tool_name(agent_type: AgentType, raw_input: &Option) -> Option { - if !is_codebuddy_family(agent_type) { + if agent_type != AgentType::CodeBuddy { return None; } let text = raw_input.as_deref()?; @@ -13866,7 +13844,7 @@ fn codebuddy_deferred_tool_name(agent_type: AgentType, raw_input: &Option Option { - if !is_codebuddy_family(agent_type) { + if agent_type != AgentType::CodeBuddy { return None; } // Cheap substring guard before parsing a potentially large payload. @@ -13895,7 +13873,7 @@ fn codebuddy_meta_marks_subagent( agent_type: AgentType, meta: Option<&serde_json::Map>, ) -> bool { - if !is_codebuddy_family(agent_type) { + if agent_type != AgentType::CodeBuddy { return false; } let Some(meta) = meta else { @@ -14972,7 +14950,7 @@ fn codebuddy_meta_marks_background( agent_type: AgentType, meta: Option<&serde_json::Map>, ) -> bool { - if !is_codebuddy_family(agent_type) { + if agent_type != AgentType::CodeBuddy { return false; } meta.and_then(|m| m.get("codebuddy.ai/isBackground")) @@ -14981,27 +14959,21 @@ 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>, ) -> bool { - if !is_codebuddy_family(agent_type) { + 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 @@ -15024,14 +14996,14 @@ 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 the CodeBuddy -/// family; 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, chunk_meta: Option<&serde_json::Map>, ) -> bool { - if !is_codebuddy_family(agent_type) { + if agent_type != AgentType::CodeBuddy { return false; } if subagent_chunk_parent_tool_use_id(agent_type, chunk_meta).is_some() { @@ -15051,11 +15023,11 @@ fn should_suppress_subagent_chunk( /// claude-agent-acp ≥0.63 on a subagent's forwarded chunks once the client /// advertises the `subagent-transcript` capability (see /// `build_client_capabilities`). -/// * The CodeBuddy family writes a FLAT `_meta["codebuddy.ai/parentToolCallId"]` +/// * 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 family's own id space is read, so no other agent's +/// 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( @@ -15064,7 +15036,7 @@ fn subagent_chunk_parent_tool_use_id( ) -> Option { // CodeBuddy's link is a flat single key, not nested under a namespace // object — read it before the namespaced pair. - if is_codebuddy_family(agent_type) { + if agent_type == AgentType::CodeBuddy { return meta? .get("codebuddy.ai/parentToolCallId")? .as_str() @@ -15212,7 +15184,7 @@ fn track_subagent_window( open: &mut HashSet, closed: &mut HashSet, ) { - if !is_codebuddy_family(agent_type) || !is_agent { + if agent_type != AgentType::CodeBuddy || !is_agent { return; } let is_final = matches!(status, Some("completed") | Some("failed")); @@ -17431,10 +17403,12 @@ 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 = subagent_chunk_parent_tool_use_id(agent_type, meta.as_ref()); emit_with_state( @@ -30650,49 +30624,6 @@ mod tests { ); } - // ─── is_codebuddy_family (the built-in and custom CLI variants) ────── - - /// A custom registration running the same CLI (e.g. `codebuddy-cn` / - /// `codebuddy-intl` for two accounts) must get the same quirk handling as - /// the built-in; ids outside the family must not. - #[test] - fn codebuddy_family_covers_builtin_and_custom_variants_only() { - assert!(is_codebuddy_family(AgentType::CodeBuddy)); - assert!(is_codebuddy_family(AgentType::Custom("codebuddy"))); - assert!(is_codebuddy_family(AgentType::Custom("codebuddy-cn"))); - assert!(is_codebuddy_family(AgentType::Custom("codebuddy-intl"))); - assert!(!is_codebuddy_family(AgentType::Custom("codebuddyx"))); - assert!(!is_codebuddy_family(AgentType::Custom("qoder-cli"))); - assert!(!is_codebuddy_family(AgentType::ClaudeCode)); - assert!(!is_codebuddy_family(AgentType::OpenCode)); - } - - /// The suppression window is the core of the sub-agent folding the custom - /// agents were missing: an open Agent window must suppress chunks for a - /// custom CodeBuddy variant exactly like the built-in. - #[test] - fn custom_codebuddy_variant_gets_subagent_suppression_and_meta_reads() { - let custom = AgentType::Custom("codebuddy-cn"); - // Window open, chunk meta absent → suppressed (the window invariant). - assert!(should_suppress_subagent_chunk(custom, true, None)); - // Window closed but the chunk's own meta marks a sub-agent → suppressed. - let meta = serde_json::json!({ "codebuddy.ai/isSubagent": true }); - assert!(should_suppress_subagent_chunk(custom, false, meta.as_object())); - // Window closed, no marker → emitted. - assert!(!should_suppress_subagent_chunk(custom, false, None)); - // Meta-based classification and title rewrite work for the variant. - let agent_meta = serde_json::json!({ "codebuddy.ai/toolName": "Agent" }); - assert!(codebuddy_meta_marks_subagent(custom, agent_meta.as_object())); - let mut overrides: HashMap = HashMap::new(); - assert_eq!( - resolve_rewritten_title(custom, &None, "tc1", false, true, &mut overrides).as_deref(), - Some("agent") - ); - // Still scoped: a non-family agent with the same meta is untouched. - assert!(!codebuddy_meta_marks_subagent(AgentType::Qoder, agent_meta.as_object())); - assert!(!should_suppress_subagent_chunk(AgentType::Qoder, true, None)); - } - // ─── codebuddy_meta_marks_subagent ─────────────────────────────────── #[test] @@ -30909,20 +30840,13 @@ mod tests { } #[test] - fn codebuddy_chunk_parent_reads_the_flat_meta_only_for_the_family() { + fn codebuddy_chunk_parent_reads_the_flat_meta_only_for_codebuddy() { let valid = serde_json::json!({ "codebuddy.ai/parentToolCallId": "chatcmpl-tool-1" }); - // The built-in and custom variants all read it. - for agent in [ - AgentType::CodeBuddy, - AgentType::Custom("codebuddy-cn"), - AgentType::Custom("codebuddy-intl"), - ] { - assert_eq!( - subagent_chunk_parent_tool_use_id(agent, valid.as_object()), - Some("chatcmpl-tool-1".to_string()) - ); - } - // The same flat key on a non-family agent must not alias into routing. + 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 @@ -30946,6 +30870,147 @@ mod tests { 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" } }); diff --git a/src-tauri/src/parsers/claude.rs b/src-tauri/src/parsers/claude.rs index a8e60bd199..7cddb8bc00 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, AgentParser, - ParseError, + ParseError, TIMELINE_ENTRY_MAX_CHARS, }; /// Regex that matches Claude Code system-injected XML tags and their content. @@ -3311,14 +3311,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 b0bab94f04..4638a7c1b1 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,6 +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, AgentParser, ParseError, + TIMELINE_ENTRY_MAX_CHARS, }; /// Resolve CodeBuddy's config dir, honoring `CODEBUDDY_CONFIG_DIR`, else @@ -1002,16 +1004,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); @@ -1019,6 +1034,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 }; @@ -1035,6 +1051,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) { @@ -1042,11 +1063,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) = @@ -1058,21 +1108,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, @@ -1091,8 +1149,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; @@ -1110,7 +1168,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. @@ -1262,6 +1320,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 69a1a0cc30..102ed4fb8f 100644 --- a/src-tauri/src/parsers/kimi_code.rs +++ b/src-tauri/src/parsers/kimi_code.rs @@ -645,7 +645,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 3bd9e7a670..fbe97b4b46 100644 --- a/src-tauri/src/parsers/mod.rs +++ b/src-tauri/src/parsers/mod.rs @@ -550,6 +550,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 a7622c5c34..38548d7871 100644 --- a/src/contexts/acp-connections-context.tsx +++ b/src/contexts/acp-connections-context.tsx @@ -223,10 +223,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 a44d9e765f..a089d0a11d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -261,9 +261,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 097850fa1a..723b842721 100644 --- a/src/stores/conversation-runtime-store.ts +++ b/src/stores/conversation-runtime-store.ts @@ -2196,10 +2196,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,