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}