Skip to main content

agentmux_srv/backend/subagent_watcher/
parse.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Free-standing parsing/path utilities used by the rest of `subagent_watcher`
5//! — none of these need `&self`. Covers: JSONL line parsing, journal-count
6//! reading, workflow/session id derivation from a path, the block-delete
7//! cascade subscriber, and Claude config dir resolution.
8
9use std::io::{BufRead, BufReader, Seek, SeekFrom};
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12
13use super::types::{SubagentEvent, SubagentEventType};
14use super::SubagentWatcher;
15
16// ── Tool-call payload bounds ────────────────────────────────────────────
17//
18// Both limits are in CHARS, never bytes, so a cut can't split a codepoint.
19// See docs/reports/REPORT_TOOL_SUMMARY_FORMATTING_ARCHITECTURE_2026_09_06.md
20// §4.1 and §9 (measured input sizes).
21
22/// Chars kept per top-level field of a tool call's input. Every subagent
23/// call measured fits whole; an Edit's file contents or an Agent prompt
24/// does not, and only needs to show its start.
25const MAX_INPUT_FIELD_CHARS: usize = 500;
26
27/// Chars of a tool result kept for the same feed. Larger than the input
28/// budget because a result is the thing a reader is usually scanning.
29const MAX_RESULT_PREVIEW_CHARS: usize = 500;
30
31/// Cut to `max` CHARS (never mid-codepoint), appending an ellipsis when it
32/// actually cut something.
33fn truncate_chars(s: &str, max: usize) -> String {
34    match s.char_indices().nth(max) {
35        Some((byte_idx, _)) => format!("{}...", &s[..byte_idx]),
36        None => s.to_string(),
37    }
38}
39
40/// A tool call's `input`, kept structured but bounded: each top-level string
41/// is cut to `MAX_INPUT_FIELD_CHARS`, and a nested array/object whose JSON is
42/// longer becomes that JSON, cut the same way.
43///
44/// Which field to show is decided by the frontend's `toolDetail`
45/// (`frontend/app/view/agent/tool-meta/tool-descriptors.ts`), the same rule
46/// the agent pane uses, so the Swarm feed can't drift from it.
47fn bounded_tool_input(input: &serde_json::Value) -> serde_json::Value {
48    use serde_json::Value;
49    fn bound(v: &Value) -> Value {
50        match v {
51            Value::String(s) => Value::String(truncate_chars(s, MAX_INPUT_FIELD_CHARS)),
52            Value::Array(_) | Value::Object(_) => {
53                let json = v.to_string();
54                if json.chars().count() <= MAX_INPUT_FIELD_CHARS {
55                    v.clone()
56                } else {
57                    Value::String(truncate_chars(&json, MAX_INPUT_FIELD_CHARS))
58                }
59            }
60            scalar => scalar.clone(),
61        }
62    }
63    match input {
64        Value::Object(map) => Value::Object(map.iter().map(|(k, v)| (k.clone(), bound(v))).collect()),
65        other => bound(other),
66    }
67}
68
69/// A tool result's text. A plain string is used as-is; anything else (the
70/// common `[{"type":"text","text":…}]` block array, or a bare object) falls
71/// back to compact JSON, same as before.
72fn result_content_text(content: &serde_json::Value) -> String {
73    match content.as_str() {
74        Some(s) => s.to_string(),
75        None => content.to_string(),
76    }
77}
78
79// ── Block-delete cascade subscriber ─────────────────────────────────────
80
81/// Subscribe to the reducer's `srv_events_tx` broadcast and prune `watcher`'s
82/// per-block state on `Event::BlockDeleted`/`TabDeleted`/`WorkspaceDeleted` —
83/// the robust backstop described on `SubagentWatcher::prune_block`'s doc
84/// comment. Mirrors `crates/cef/src/srv_ipc.rs`'s cascaded-block-id
85/// extraction (same three-arm match, same rationale: `TabDeleted`/
86/// `WorkspaceDeleted` never emit a per-block event of their own — see
87/// `reducer/tab.rs::handle_delete_tab`'s doc comment — so `block_ids` is the
88/// only signal for a block that cascaded out via its tab/workspace) and
89/// `server/mux_obj_bridge.rs::run_mux_obj_bridge`'s subscribe-loop
90/// plumbing (lag/close handling).
91pub fn spawn_block_prune_subscriber(
92    watcher: Arc<SubagentWatcher>,
93    mut events_rx: tokio::sync::broadcast::Receiver<agentmux_common::ipc::Event>,
94) -> tokio::task::JoinHandle<()> {
95    tokio::spawn(async move {
96        loop {
97            match events_rx.recv().await {
98                Ok(event) => {
99                    let cascaded_block_ids: &[String] = match &event {
100                        agentmux_common::ipc::Event::BlockDeleted { block_id, .. } => {
101                            std::slice::from_ref(block_id)
102                        }
103                        agentmux_common::ipc::Event::TabDeleted { block_ids, .. } => block_ids.as_slice(),
104                        agentmux_common::ipc::Event::WorkspaceDeleted { block_ids, .. } => block_ids.as_slice(),
105                        _ => &[],
106                    };
107                    for block_id in cascaded_block_ids {
108                        watcher.prune_block_and_notify(block_id);
109                    }
110                }
111                Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
112                    // Same non-fatal, no-automatic-recovery handling as
113                    // mux_obj_bridge.rs's identical arm: a lag here means a
114                    // stale swarm-pane row could persist until its block's
115                    // next delete-adjacent event, not silent data loss.
116                    tracing::warn!(
117                        skipped = n,
118                        "subagent block-prune subscriber lagged; some BlockDeleted/TabDeleted/WorkspaceDeleted events were dropped"
119                    );
120                }
121                Err(tokio::sync::broadcast::error::RecvError::Closed) => {
122                    tracing::info!("subagent block-prune subscriber exiting: events channel closed");
123                    return;
124                }
125            }
126        }
127    })
128}
129
130// ── JSONL parsing ─────────────────────────────────────────────────────────
131
132/// Metadata extracted from the first JSONL line (the subagent init record).
133pub(super) struct JsonlMeta {
134    pub(super) slug: String,
135    pub(super) model: Option<String>,
136    /// The line's own `"parentUuid"` field, verbatim (SPEC §9.2 — `SubAgent.
137    /// spawned_from_agent_id`). `None` in every real transcript checked so
138    /// far; captured defensively since this line is already parsed.
139    pub(super) parent_uuid: Option<String>,
140}
141
142/// Read a JSONL file from a byte offset, parsing new subagent events.
143/// Returns (events, new_offset, optional_meta).
144pub(super) fn read_jsonl_from_offset(
145    path: &Path,
146    offset: u64,
147) -> Result<(Vec<SubagentEvent>, u64, Option<JsonlMeta>), String> {
148    let file = std::fs::File::open(path).map_err(|e| format!("open: {e}"))?;
149    let file_len = file.metadata().map_err(|e| format!("metadata: {e}"))?.len();
150
151    if file_len <= offset {
152        return Ok((Vec::new(), offset, None));
153    }
154
155    let mut reader = BufReader::new(file);
156    reader
157        .seek(SeekFrom::Start(offset))
158        .map_err(|e| format!("seek: {e}"))?;
159
160    let mut events = Vec::new();
161    let mut meta = None;
162    let mut current_offset = offset;
163
164    for line_result in reader.lines() {
165        let line = match line_result {
166            Ok(l) => l,
167            Err(_) => break,
168        };
169        current_offset += line.len() as u64 + 1; // +1 for newline
170
171        if line.trim().is_empty() {
172            continue;
173        }
174
175        let value: serde_json::Value = match serde_json::from_str(&line) {
176            Ok(v) => v,
177            Err(_) => continue,
178        };
179
180        // Extract metadata from init/config lines
181        if offset == 0 && meta.is_none() {
182            let parent_uuid = value
183                .get("parentUuid")
184                .and_then(|v| v.as_str())
185                .map(|s| s.to_string());
186            if let Some(slug) = value.get("slug").and_then(|v| v.as_str()) {
187                meta = Some(JsonlMeta {
188                    slug: slug.to_string(),
189                    model: value
190                        .get("model")
191                        .and_then(|v| v.as_str())
192                        .map(|s| s.to_string()),
193                    parent_uuid: parent_uuid.clone(),
194                });
195            }
196            if meta.is_none() {
197                if let Some(agent_id) = value.get("agentId").and_then(|v| v.as_str()) {
198                    meta = Some(JsonlMeta {
199                        slug: value
200                            .get("slug")
201                            .and_then(|v| v.as_str())
202                            .unwrap_or(agent_id)
203                            .to_string(),
204                        model: value
205                            .get("model")
206                            .and_then(|v| v.as_str())
207                            .map(|s| s.to_string()),
208                        parent_uuid,
209                    });
210                }
211            }
212        }
213
214        let timestamp = value
215            .get("timestamp")
216            .and_then(parse_event_timestamp)
217            .unwrap_or_else(now_millis);
218
219        let event_type = parse_event_type(&value);
220        if let Some(et) = event_type {
221            let line_agent_id = value
222                .get("agentId")
223                .and_then(|v| v.as_str())
224                .unwrap_or("")
225                .to_string();
226
227            events.push(SubagentEvent {
228                agent_id: line_agent_id,
229                event_type: et,
230                timestamp,
231            });
232        }
233    }
234
235    Ok((events, current_offset, meta))
236}
237
238/// Read just the subagent's own initial task prompt from the first JSONL
239/// line (a `"type":"user"` init record) — used by `subagent.GenerateName` as
240/// the source text for the Haiku naming call. Deliberately bypasses the
241/// events cache/offset machinery `read_jsonl_from_offset` uses: naming only
242/// ever needs the first line, is called at most once per subagent (cached
243/// via `display_name` thereafter), and must work even for a subagent whose
244/// events haven't been scanned into `SubagentState` yet.
245///
246/// `message.content` is either a plain string or an array of content blocks
247/// (mirrors the two shapes `parse_event_type`'s "assistant" arm already
248/// handles) — both are accepted here.
249pub(crate) fn read_task_prompt(jsonl_path: &str) -> Option<String> {
250    let file = std::fs::File::open(jsonl_path).ok()?;
251    let mut reader = BufReader::new(file);
252    let mut first_line = String::new();
253    reader.read_line(&mut first_line).ok()?;
254
255    let value: serde_json::Value = serde_json::from_str(first_line.trim()).ok()?;
256    if value.get("type").and_then(|v| v.as_str()) != Some("user") {
257        return None;
258    }
259    let content = value.get("message")?.get("content")?;
260
261    if let Some(text) = content.as_str() {
262        let trimmed = text.trim();
263        return (!trimmed.is_empty()).then(|| trimmed.to_string());
264    }
265
266    if let Some(arr) = content.as_array() {
267        let texts: Vec<&str> = arr
268            .iter()
269            .filter_map(|block| {
270                if block.get("type").and_then(|t| t.as_str()) == Some("text") {
271                    block.get("text").and_then(|t| t.as_str())
272                } else {
273                    None
274                }
275            })
276            .collect();
277        let joined = texts.join("\n").trim().to_string();
278        return (!joined.is_empty()).then_some(joined);
279    }
280
281    None
282}
283
284/// Parse a JSONL line into a SubagentEventType based on the `type` field.
285pub(super) fn parse_event_type(value: &serde_json::Value) -> Option<SubagentEventType> {
286    let event_type = value.get("type").and_then(|v| v.as_str())?;
287
288    match event_type {
289        "assistant" => {
290            let content = value
291                .get("message")
292                .and_then(|m| m.get("content"))
293                .and_then(|c| {
294                    if let Some(arr) = c.as_array() {
295                        let texts: Vec<&str> = arr
296                            .iter()
297                            .filter_map(|block| {
298                                if block.get("type").and_then(|t| t.as_str()) == Some("text") {
299                                    block.get("text").and_then(|t| t.as_str())
300                                } else {
301                                    None
302                                }
303                            })
304                            .collect();
305                        if texts.is_empty() {
306                            None
307                        } else {
308                            Some(texts.join("\n"))
309                        }
310                    } else {
311                        c.as_str().map(|s| s.to_string())
312                    }
313                })
314                .unwrap_or_default();
315            Some(SubagentEventType::Text { content })
316        }
317        "tool_use" => {
318            let name = value
319                .get("name")
320                .or_else(|| value.get("tool_name"))
321                .and_then(|v| v.as_str())
322                .unwrap_or("unknown")
323                .to_string();
324            let input = value.get("input").map(bounded_tool_input).unwrap_or_default();
325            Some(SubagentEventType::ToolUse { name, input })
326        }
327        "tool_result" => {
328            let is_error = value
329                .get("is_error")
330                .and_then(|v| v.as_bool())
331                .unwrap_or(false);
332            let preview = value
333                .get("content")
334                .or_else(|| value.get("output"))
335                .map(|v| truncate_chars(&result_content_text(v), MAX_RESULT_PREVIEW_CHARS))
336                .unwrap_or_default();
337            Some(SubagentEventType::ToolResult { is_error, preview })
338        }
339        "progress" => {
340            let output = value
341                .get("output")
342                .or_else(|| value.get("content"))
343                .and_then(|v| v.as_str())
344                .unwrap_or("")
345                .to_string();
346            Some(SubagentEventType::Progress { output })
347        }
348        "result" => {
349            let content = value
350                .get("result")
351                .or_else(|| value.get("content"))
352                .map(|v| {
353                    if let Some(s) = v.as_str() {
354                        s.to_string()
355                    } else {
356                        v.to_string()
357                    }
358                })
359                .unwrap_or_else(|| "Subagent completed".to_string());
360            Some(SubagentEventType::Result { content })
361        }
362        _ => None,
363    }
364}
365
366/// Epoch-millis for a subagent JSONL line's `timestamp` field, tolerant of
367/// BOTH shapes that occur in real transcripts.
368///
369/// Claude Code writes an ISO-8601 STRING (`"2026-07-03T08:09:20.743Z"`), but
370/// this was read with a bare `as_u64()`, which returns `None` for a string
371/// and silently fell through to `now_millis()`. Every replayed historical
372/// event therefore claimed to have happened *at replay time*.
373///
374/// That is not a cosmetic timestamp bug: `last_event_at` becomes
375/// `subagentToActivity`'s `endedAt` (activity/subagent-adapter.ts), and the
376/// Activity Dock keeps a terminal row only while
377/// `now - endedAt < RETENTION_MS[status]`. With `endedAt ≈ now`, every
378/// long-dead subagent replayed on pane (re)open passed that window and
379/// rendered as a live-looking dock row, then vanished a few seconds later
380/// when it aged out — the "dock flashes on load, then disappears" report.
381/// With real historical timestamps these rows are filtered out immediately
382/// and never paint at all.
383///
384/// Numeric epoch-millis are still accepted unchanged (other providers, and
385/// any future writer that emits a number). An unparseable value returns
386/// `None` so the caller keeps its existing `now_millis()` fallback — a
387/// genuinely-live event with a malformed timestamp is better dated "now"
388/// than dropped to the epoch.
389///
390/// See docs/retro/retro-activitydock-appears-on-agent-pane-load-2026-09-02.md.
391pub(super) fn parse_event_timestamp(v: &serde_json::Value) -> Option<u64> {
392    if let Some(n) = v.as_u64() {
393        return Some(n);
394    }
395    let s = v.as_str()?;
396    chrono::DateTime::parse_from_rfc3339(s)
397        .ok()
398        .map(|dt| dt.timestamp_millis())
399        .filter(|ms| *ms >= 0)
400        .map(|ms| ms as u64)
401}
402
403/// Modification time of `path`, or `UNIX_EPOCH` if it can't be read (a
404/// vanished/permission-denied file sorts oldest — excluded first by
405/// `scan_subagents_dir`'s recency cap rather than crashing the scan).
406pub(super) fn file_mtime(path: &Path) -> std::time::SystemTime {
407    std::fs::metadata(path)
408        .and_then(|m| m.modified())
409        .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
410}
411
412/// Walk up `path`'s ancestors (parent, grandparent, ...) and return the
413/// first one that already exists on disk. Used by `watch_agent` when the
414/// target directory doesn't exist yet — `notify::Watcher::watch` fails
415/// outright on a nonexistent path, so this finds the closest directory that
416/// can actually be watched right now (a later-created descendant is still
417/// picked up, since the watch is recursive).
418///
419/// Never walks above the user's home directory. Without a floor, a fresh
420/// environment where even `~/.config` doesn't exist yet (ephemeral
421/// dev/CI/container — this project's own docs describe several such setups)
422/// would walk all the way to `$HOME` or further, and
423/// `watcher.watch(&watched_dir, RecursiveMode::Recursive)` performs a
424/// synchronous, blocking directory walk of whatever it's handed — recursing
425/// the entire home directory (or beyond) from inside the async
426/// `handle_reactive_register` request handler risks a long stall and, on
427/// Linux, exhausting the OS-wide inotify watch-count limit. Returns `None`
428/// (giving up on watching rather than risking an unbounded walk) if `path`
429/// isn't under the home directory at all, or if no existing ancestor is
430/// found within that bound.
431pub(super) fn nearest_existing_ancestor(path: &Path) -> Option<PathBuf> {
432    let floor = dirs::home_dir()?;
433    path.ancestors()
434        .skip(1)
435        .take_while(|p| p.starts_with(&floor))
436        .find(|p| p.exists())
437        .map(|p| p.to_path_buf())
438}
439
440/// Extract the workflow id from a path under `.../subagents/workflows/<id>/...`.
441/// Returns None for direct (non-workflow) subagent files.
442pub(super) fn parse_workflow_id(path: &Path) -> Option<String> {
443    let comps: Vec<&str> = path.iter().filter_map(|c| c.to_str()).collect();
444    comps.windows(3).find_map(|w| {
445        (w[0] == "subagents" && w[1] == "workflows" && !w[2].ends_with(".jsonl"))
446            .then(|| w[2].to_string())
447    })
448}
449
450/// Session id = the name of the directory containing `subagents/`. Workflow
451/// member files are nested (`subagents/workflows/<wf>/agent-*.jsonl`), so walk
452/// ancestors instead of assuming a fixed depth.
453pub(super) fn derive_session_id(path: &Path) -> String {
454    let mut dir = path.parent();
455    while let Some(d) = dir {
456        if d.file_name().and_then(|n| n.to_str()) == Some("subagents") {
457            return d
458                .parent()
459                .and_then(|p| p.file_name())
460                .and_then(|n| n.to_str())
461                .unwrap_or("unknown")
462                .to_string();
463        }
464        dir = d.parent();
465    }
466    "unknown".to_string()
467}
468
469/// Count new `started` / `result` records in a workflow journal from `offset`.
470/// Returns (started, results, new_offset).
471///
472/// Reads line-by-line via `read_until(b'\n', ..)` rather than `BufRead::lines()`
473/// so a trailing line with no `\n` yet (the writer racing a partial append) is
474/// never counted or consumed: `new_offset` only ever advances past complete,
475/// newline-terminated lines, so the next call re-reads that partial line whole
476/// once the rest of it lands, instead of seeking mid-line and silently losing
477/// the record's leading bytes (and the record itself, since the resulting
478/// truncated JSON fails to parse).
479pub(super) fn read_journal_counts(path: &Path, offset: u64) -> Result<(usize, usize, u64), String> {
480    let file = std::fs::File::open(path).map_err(|e| format!("open: {e}"))?;
481    let file_len = file.metadata().map_err(|e| format!("metadata: {e}"))?.len();
482    if file_len <= offset {
483        return Ok((0, 0, offset));
484    }
485
486    let mut reader = BufReader::new(file);
487    reader
488        .seek(SeekFrom::Start(offset))
489        .map_err(|e| format!("seek: {e}"))?;
490
491    let (mut started, mut results) = (0usize, 0usize);
492    let mut current_offset = offset;
493    loop {
494        let mut buf = Vec::new();
495        let bytes_read = reader
496            .read_until(b'\n', &mut buf)
497            .map_err(|e| format!("read_until: {e}"))?;
498        if bytes_read == 0 {
499            break; // EOF
500        }
501        if !buf.ends_with(b"\n") {
502            break; // trailing partial line — leave it unconsumed for next time
503        }
504        current_offset += bytes_read as u64;
505
506        let line = String::from_utf8_lossy(&buf);
507        let trimmed = line.trim_end_matches(['\n', '\r']);
508        let value: serde_json::Value = match serde_json::from_str(trimmed) {
509            Ok(v) => v,
510            Err(_) => continue,
511        };
512        match value.get("type").and_then(|v| v.as_str()) {
513            Some("started") => started += 1,
514            Some("result") => results += 1,
515            _ => {}
516        }
517    }
518    Ok((started, results, current_offset))
519}
520
521pub(super) use agentmux_common::time::now_ms_u64 as now_millis;
522
523/// Derive the Claude Code config directory for a host agent. Only matches
524/// reality for an agent with an explicit per-account auth dir —
525/// prefer `resolve_claude_config_dir` when the block's meta is available.
526pub fn derive_claude_config_dir(agent_id: &str) -> Option<PathBuf> {
527    let home = dirs::home_dir()?;
528    let config_dir = home
529        .join(".config")
530        .join(format!("claude-{}", agent_id.to_lowercase()));
531    Some(config_dir)
532}
533
534/// The authoritative Claude Code config directory for a block.
535///
536/// `bound_dir` — pass `identity::resolver::resolve_bound_oauth_config_dir`'s
537/// result here. It wins when present: for an agent bound to an explicit
538/// account, it's the ONLY correct answer — `cmd:env.CLAUDE_CONFIG_DIR`
539/// below is a write-once launch-time snapshot of the generic shared-provider
540/// dir, while the identity-bound agent's CLI actually runs (and writes
541/// subagent files) under its own identity dir, re-resolved fresh on every
542/// turn (`inject_identity_env_with_broker`,
543/// `SPEC_PROVIDER_ISOLATION_2026_06_20.md` §4.3) — `cmd:env` is never
544/// updated to match. Confirmed live: a real dispatch's transcript landed
545/// under the identity dir while the watcher, following `cmd:env` alone, was
546/// watching the generic dir and never saw it — see
547/// `SPEC_SUBAGENT_WATCHER_IDENTITY_BOUND_CONFIG_DIR_2026_08_22.md`.
548///
549/// Falls back to `cmd:env.CLAUDE_CONFIG_DIR` (written by the launch flow
550/// before spawn — see srv's `provider.ensureauthdir`) when `bound_dir`
551/// is `None` (an ambient/unbound agent, where `cmd:env` IS correct and
552/// stays correct — nothing to re-resolve). Falls back further to
553/// `derive_claude_config_dir`'s legacy `~/.config/claude-<agent_id>` guess
554/// only when `cmd:env` isn't set yet either.
555///
556/// This distinction matters: `derive_claude_config_dir`'s guess only holds
557/// for an agent with an explicit per-account auth dir. Any agent
558/// without one launches under the shared default at
559/// `~/.agentmux/shared/providers/claude/`, a completely different path that
560/// the guess never matches — silently disabling subagent tracking for that
561/// agent forever (confirmed live: repeated "config dir does not exist yet"
562/// over 38+ minutes and multiple re-registrations, for an agent that had, in
563/// fact, already spawned subagents — just under the real shared path). See
564/// docs/specs/archive/REPORT_SWARM_SUBAGENT_HISTORY_FLOOD_2026_07_07.md.
565pub fn resolve_claude_config_dir(
566    meta: &crate::backend::obj::MetaMapType,
567    agent_id: &str,
568    bound_dir: Option<PathBuf>,
569) -> Option<PathBuf> {
570    bound_dir
571        .or_else(|| {
572            crate::backend::blockcontroller::cmd_env_of(meta)
573                .remove("CLAUDE_CONFIG_DIR")
574                .map(PathBuf::from)
575        })
576        .or_else(|| derive_claude_config_dir(agent_id))
577}
578
579#[cfg(test)]
580mod summary_tests {
581    use super::*;
582    use serde_json::json;
583
584    // The wire shape `swarm-model.ts`'s `SubagentEventType` mirrors: the
585    // structured input, not a pre-formatted string.
586    #[test]
587    fn a_tool_use_line_carries_its_input_structured() {
588        let line = json!({
589            "type": "tool_use",
590            "name": "WebFetch",
591            "input": { "url": "https://example.com/a?b=1", "prompt": "summarise" },
592        });
593        let event = parse_event_type(&line).expect("tool_use parses");
594        assert_eq!(
595            serde_json::to_value(&event).unwrap(),
596            json!({
597                "type": "tool_use",
598                "name": "WebFetch",
599                "input": { "url": "https://example.com/a?b=1", "prompt": "summarise" },
600            })
601        );
602    }
603
604    #[test]
605    fn a_tool_use_line_without_input_carries_null() {
606        let event = parse_event_type(&json!({ "type": "tool_use", "name": "Bash" })).unwrap();
607        assert!(matches!(event, SubagentEventType::ToolUse { input: serde_json::Value::Null, .. }));
608    }
609
610    #[test]
611    fn a_small_input_crosses_unchanged() {
612        let input = json!({ "command": "ls -la", "timeout": 5, "run_in_background": false, "opts": ["a"] });
613        assert_eq!(bounded_tool_input(&input), input);
614    }
615
616    #[test]
617    fn long_string_fields_are_cut_per_field() {
618        // An Edit's file contents is the measured tail; its path must survive.
619        let input = json!({ "file_path": "/x/y.rs", "new_string": "x".repeat(5000) });
620        let got = bounded_tool_input(&input);
621        assert_eq!(got["file_path"], "/x/y.rs");
622        let cut = got["new_string"].as_str().unwrap();
623        assert_eq!(cut.chars().count(), MAX_INPUT_FIELD_CHARS + 3, "cut plus the three-dot marker");
624    }
625
626    #[test]
627    fn a_large_nested_value_becomes_its_cut_json() {
628        let edits: Vec<_> = (0..100).map(|i| json!({ "old_string": i, "new_string": i })).collect();
629        let got = bounded_tool_input(&json!({ "file_path": "/f", "edits": edits }));
630        assert_eq!(got["file_path"], "/f");
631        let cut = got["edits"].as_str().expect("oversized array is replaced by a string");
632        assert!(cut.starts_with("[{"), "{cut}");
633        assert_eq!(cut.chars().count(), MAX_INPUT_FIELD_CHARS + 3);
634    }
635
636    #[test]
637    fn a_non_object_input_is_bounded_too() {
638        assert_eq!(bounded_tool_input(&json!("short")), json!("short"));
639        let got = bounded_tool_input(&json!("é".repeat(MAX_INPUT_FIELD_CHARS + 1)));
640        assert_eq!(got.as_str().unwrap().chars().count(), MAX_INPUT_FIELD_CHARS + 3);
641    }
642
643    #[test]
644    fn a_string_result_is_used_verbatim() {
645        assert_eq!(result_content_text(&json!("plain output")), "plain output");
646    }
647
648    #[test]
649    fn a_block_array_result_falls_back_to_json() {
650        let content = json!([{ "type": "text", "text": "hi" }]);
651        assert_eq!(result_content_text(&content), content.to_string());
652    }
653
654    #[test]
655    fn truncation_counts_chars_and_marks_that_it_cut() {
656        assert_eq!(truncate_chars("abcdef", 3), "abc...");
657        assert_eq!(truncate_chars("abc", 3), "abc", "exact length must not gain an ellipsis");
658        assert_eq!(truncate_chars("ab", 3), "ab");
659    }
660
661    #[test]
662    fn truncation_never_splits_a_multibyte_char() {
663        // The old code tested byte length and cut by char index, so a string
664        // over the byte budget but under the char budget silently escaped
665        // truncation entirely (§4.1 of the report).
666        let s = "é".repeat(150); // 300 bytes, 150 chars
667        assert_eq!(truncate_chars(&s, 200), s, "under the CHAR budget — unchanged");
668        let long = "é".repeat(250);
669        let got = truncate_chars(&long, 200);
670        assert_eq!(got.chars().count(), 203, "200 chars + the three-dot marker");
671    }
672}