返回 CodeWhale
truncate.rs
根目录 / crates / tui / src / tools / truncate.rs
1 //! Tool-output spillover writer (#422).
2 //!
3 //! When a tool produces output that's too large to land in the model's
4 //! context budget, we want two things at once:
5 //!
6 //! 1. The transcript / tool-cell renders a bounded preview so the UI
7 //! stays scannable.
8 //! 2. The full router input is preserved under its origin session so bounded
9 //! retrieval and the raw-detail pager can inspect it without leaking a
10 //! process-global filesystem path.
11 //!
12 //! The default adaptive path writes immutable artifacts under
13 //! `~/.codewhale/sessions/<session>/artifacts/`. The historical
14 //! `~/.codewhale/tool_outputs/<sanitised-id>.txt` directory remains only for
15 //! classic-routing compatibility, protected by a digest-bound origin sidecar.
16 //!
17 //! Boot prune drops files whose mtime is older than [`SPILLOVER_MAX_AGE`]
18 //! (7 days). Prune failures are logged and never fatal — the user
19 //! shouldn't see startup wedge because of a stale tool-output file.
20 //!
21 //! ## Live callers
22 //!
23 //! * [`apply_spillover`] — invoked from the engine's tool-execution
24 //! path (`turn_loop.rs`) so any successful tool result over
25 //! [`SPILLOVER_THRESHOLD_BYTES`] spills to disk and the model
26 //! receives a bounded plain preview: a [`SPILLOVER_HEAD_BYTES`] head,
27 //! a short retained tail, and an honest footer naming the on-disk
28 //! path of the full output plus a one-line recovery instruction.
29 //! * Boot prune in `main.rs` deletes files older than
30 //! [`SPILLOVER_MAX_AGE`].
31 //!
32 //! UI-side rendering is owned by `tui/history.rs::render_spillover_annotation`;
33 //! it exposes a calm expand affordance and the tool-details shortcut opens the
34 //! full retained output.
35
36 use std::fs;
37 use std::io;
38 use std::path::{Path, PathBuf};
39 use std::time::{Duration, SystemTime};
40
41 use crate::tools::spec::ToolResult;
42
43 /// Name of the spillover directory under the CodeWhale home.
44 pub const SPILLOVER_DIR_NAME: &str = "tool_outputs";
45
46 const LEGACY_SPILLOVER_OWNER_SCHEMA_VERSION: u32 = 1;
47
48 /// Session proof for compatibility payloads kept in the historical global
49 /// `tool_outputs/` directory.
50 ///
51 /// The payload remains in its legacy location so classic-routing rollback and
52 /// existing detail pagers keep working, but model retrieval is authorized only
53 /// when this sidecar names the active origin session and still matches the
54 /// immutable bytes being returned.
55 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
56 pub(crate) struct LegacySpilloverOwnership {
57 pub schema_version: u32,
58 pub origin_session: String,
59 pub digest: String,
60 pub size_bytes: u64,
61 }
62
63 /// Default threshold above which a tool result is a candidate for
64 /// spillover. Mirrors the `MAX_MEMORY_SIZE` ceiling we use elsewhere
65 /// for "too large to inline" so the rules feel consistent. Wired
66 /// callers can pass a different value if a tool family has different
67 /// economics.
68 pub const SPILLOVER_THRESHOLD_BYTES: usize = 100 * 1024; // 100 KiB
69
70 /// Default boot-prune age. Older spillover files are deleted on
71 /// startup to keep `~/.codewhale/tool_outputs/` from growing without
72 /// bound. Mirrors the workspace-snapshot 7-day default.
73 pub const SPILLOVER_MAX_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60);
74
75 #[cfg(test)]
76 static TEST_SPILLOVER_ROOT: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
77
78 #[cfg(test)]
79 pub(crate) static TEST_SPILLOVER_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
80
81 /// Resolve `~/.codewhale/tool_outputs/`. Returns `None` if the home
82 /// directory can't be determined (CI containers occasionally hit
83 /// this). Callers should treat `None` as "spillover unavailable" and
84 /// degrade gracefully rather than fail the tool call.
85 #[must_use]
86 pub fn spillover_root() -> Option<PathBuf> {
87 #[cfg(test)]
88 if let Some(root) = TEST_SPILLOVER_ROOT
89 .lock()
90 .unwrap_or_else(|err| err.into_inner())
91 .clone()
92 {
93 return Some(root);
94 }
95
96 let home = crate::config::effective_home_dir()?;
97 let primary = home.join(".codewhale").join(SPILLOVER_DIR_NAME);
98 let legacy = home.join(".deepseek").join(SPILLOVER_DIR_NAME);
99 if primary.exists() || !legacy.exists() {
100 return Some(primary);
101 }
102 Some(legacy)
103 }
104
105 /// Override the spillover root for tests without mutating `$HOME`.
106 #[cfg(test)]
107 pub(crate) fn set_test_spillover_root(root: Option<PathBuf>) -> Option<PathBuf> {
108 let mut guard = TEST_SPILLOVER_ROOT
109 .lock()
110 .unwrap_or_else(|err| err.into_inner());
111 std::mem::replace(&mut *guard, root)
112 }
113
114 /// Resolve the spillover-file path for a tool call id. Sanitises the
115 /// id so that a hostile value can't escape the storage directory.
116 /// Returns `None` for empty / fully-invalid ids; the caller should
117 /// treat that as "spillover unavailable" and skip the write.
118 #[must_use]
119 pub fn spillover_path(id: &str) -> Option<PathBuf> {
120 let sanitised = sanitise_id(id)?;
121 Some(spillover_root()?.join(format!("{sanitised}.txt")))
122 }
123
124 #[must_use]
125 pub(crate) fn legacy_spillover_ownership_path(payload_path: &Path) -> PathBuf {
126 payload_path.with_extension("owner.json")
127 }
128
129 /// Publish the proof needed to retrieve a legacy-global spillover safely.
130 ///
131 /// Payload publication happens first. If this atomic sidecar write fails, the
132 /// payload is deliberately left unowned and therefore inaccessible through
133 /// `retrieve_tool_result`; callers must not advertise a retrieval hint.
134 pub(crate) fn publish_legacy_spillover_ownership(
135 payload_path: &Path,
136 session_id: &str,
137 bytes: &[u8],
138 ) -> io::Result<PathBuf> {
139 if session_id.trim().is_empty() {
140 return Err(io::Error::new(
141 io::ErrorKind::InvalidInput,
142 "legacy spillover ownership requires a session id",
143 ));
144 }
145 let ownership = LegacySpilloverOwnership {
146 schema_version: LEGACY_SPILLOVER_OWNER_SCHEMA_VERSION,
147 origin_session: session_id.to_string(),
148 digest: crate::hashing::sha256_hex(bytes),
149 size_bytes: bytes.len().try_into().unwrap_or(u64::MAX),
150 };
151 let sidecar = legacy_spillover_ownership_path(payload_path);
152 let encoded = serde_json::to_vec_pretty(&ownership)
153 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
154 crate::utils::write_atomic(&sidecar, &encoded)?;
155 Ok(sidecar)
156 }
157
158 pub(crate) fn read_legacy_spillover_ownership(
159 payload_path: &Path,
160 ) -> io::Result<LegacySpilloverOwnership> {
161 let sidecar = legacy_spillover_ownership_path(payload_path);
162 if std::fs::symlink_metadata(&sidecar)?
163 .file_type()
164 .is_symlink()
165 {
166 return Err(io::Error::new(
167 io::ErrorKind::PermissionDenied,
168 "legacy spillover ownership sidecar must not be a symlink",
169 ));
170 }
171 let ownership = serde_json::from_slice::<LegacySpilloverOwnership>(&std::fs::read(sidecar)?)
172 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
173 if ownership.schema_version != LEGACY_SPILLOVER_OWNER_SCHEMA_VERSION {
174 return Err(io::Error::new(
175 io::ErrorKind::InvalidData,
176 "unsupported legacy spillover ownership schema",
177 ));
178 }
179 Ok(ownership)
180 }
181
182 /// Resolve the spillover-file path for a SHA256 content hash. Separate
183 /// namespace (`sha_<hex>.txt`) from the tool-call-id files so legacy
184 /// SHA-addressed evidence can be recognized without colliding with
185 /// tool-call references. Retrieval still requires matching ownership
186 /// metadata. `sha` must be the raw 64-char lowercase hex digest —
187 /// case-insensitive matching is done by the caller.
188 #[must_use]
189 pub fn sha_spillover_path(sha: &str) -> Option<PathBuf> {
190 let sha = sha.trim().to_ascii_lowercase();
191 if !is_valid_sha256(&sha) {
192 return None;
193 }
194 Some(spillover_root()?.join(format!("sha_{sha}.txt")))
195 }
196
197 /// True when `s` is a 64-character lowercase ASCII hex string. Used
198 /// to detect bare SHA refs the model might pass to retrieval and to
199 /// validate input to [`sha_spillover_path`].
200 #[must_use]
201 pub fn is_valid_sha256(s: &str) -> bool {
202 s.len() == 64
203 && s.chars()
204 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
205 }
206
207 /// Write a legacy SHA-addressed spillover fixture for ownership tests.
208 #[cfg(test)]
209 pub fn write_sha_spillover(sha: &str, content: &str) -> io::Result<PathBuf> {
210 let path = sha_spillover_path(sha).ok_or_else(|| {
211 io::Error::new(
212 io::ErrorKind::InvalidInput,
213 "sha must be a 64-char lowercase hex digest",
214 )
215 })?;
216 if path.exists() {
217 return Ok(path);
218 }
219 if let Some(parent) = path.parent() {
220 fs::create_dir_all(parent)?;
221 }
222 crate::utils::write_atomic(&path, content.as_bytes())?;
223 Ok(path)
224 }
225
226 /// Write `content` to the spillover file for `id`. Creates the
227 /// parent directory if needed. Returns the resolved path on success.
228 ///
229 /// Atomic via `write` + filesystem rename guarantees from the
230 /// underlying OS — the file is created at a temp name first and
231 /// then renamed into place. Failures bubble up as `io::Error` so the
232 /// caller can decide whether to surface them.
233 pub fn write_spillover(id: &str, content: &str) -> io::Result<PathBuf> {
234 let path = spillover_path(id).ok_or_else(|| {
235 io::Error::new(
236 io::ErrorKind::InvalidInput,
237 "could not resolve spillover path (empty/invalid id or missing home directory)",
238 )
239 })?;
240 if let Some(parent) = path.parent() {
241 fs::create_dir_all(parent)?;
242 }
243 crate::utils::write_atomic(&path, content.as_bytes())?;
244 Ok(path)
245 }
246
247 /// Drop spillover files older than `max_age`. Returns the number of
248 /// files removed. Non-fatal: directory-missing returns 0; per-file
249 /// errors are logged and skipped. Mirrors
250 /// [`crate::session_manager::prune_workspace_snapshots`].
251 pub fn prune_older_than(max_age: Duration) -> io::Result<usize> {
252 let Some(root) = spillover_root() else {
253 return Ok(0);
254 };
255 if !root.exists() {
256 return Ok(0);
257 }
258 let cutoff = SystemTime::now()
259 .checked_sub(max_age)
260 .unwrap_or(SystemTime::UNIX_EPOCH);
261 let mut pruned = 0usize;
262 for entry in fs::read_dir(&root)? {
263 let entry = match entry {
264 Ok(e) => e,
265 Err(err) => {
266 tracing::warn!(target: "spillover", ?err, "skipping unreadable dir entry");
267 continue;
268 }
269 };
270 let path = entry.path();
271 if !path.is_file() {
272 continue;
273 }
274 let modified = match entry.metadata().and_then(|m| m.modified()) {
275 Ok(t) => t,
276 Err(err) => {
277 tracing::warn!(target: "spillover", ?err, ?path, "skipping unreadable mtime");
278 continue;
279 }
280 };
281 if modified < cutoff {
282 if let Err(err) = fs::remove_file(&path) {
283 tracing::warn!(target: "spillover", ?err, ?path, "spillover prune skipped a file");
284 continue;
285 }
286 pruned += 1;
287 }
288 }
289 Ok(pruned)
290 }
291
292 /// Convenience for the common "too long? spill it." pattern. If
293 /// `content` is at or below `threshold` bytes, returns `None` and the
294 /// caller keeps the inline content. Above the threshold, writes the
295 /// full content to the spillover file and returns
296 /// `Some((head, path))` where `head` is the leading slice the caller
297 /// can show inline. The trailing tail isn't returned — `path` is the
298 /// canonical reference.
299 ///
300 /// `head_bytes` controls how much inline content the caller wants to
301 /// keep. Pass `threshold` for "preserve as much as fits inline" or
302 /// a smaller value (e.g. `4 * 1024`) for "show a peek".
303 pub fn maybe_spillover(
304 id: &str,
305 content: &str,
306 threshold: usize,
307 head_bytes: usize,
308 ) -> io::Result<Option<(String, PathBuf)>> {
309 if content.len() <= threshold {
310 return Ok(None);
311 }
312 let path = write_spillover(id, content)?;
313 // Don't slice mid-utf8: walk back to a char boundary if needed.
314 let cut = head_bytes.min(content.len());
315 let cut = (0..=cut)
316 .rev()
317 .find(|&i| content.is_char_boundary(i))
318 .unwrap_or(0);
319 Ok(Some((content[..cut].to_string(), path)))
320 }
321
322 /// Inline head retained when [`apply_spillover`] truncates a tool
323 /// result. 32 KiB is large enough for the model to keep meaningful
324 /// context (a long stack trace, a `git diff` head, a directory
325 /// listing of typical depth) without consuming the lion's share of
326 /// the per-turn context budget. The full output is preserved
327 /// internally and opens in the tool details view.
328 pub const SPILLOVER_HEAD_BYTES: usize = 32 * 1024;
329 /// Inline tail retained alongside the head so compiler summaries and final
330 /// test failures are not systematically hidden by truncation.
331 pub const SPILLOVER_TAIL_BYTES: usize = 8 * 1024;
332
333 /// Inline head/tail budgets for the adaptive evidence bands. Hybrid results
334 /// keep a generous 32 KiB head + 8 KiB tail so mid-size outputs stay mostly
335 /// readable; handle-only results keep a 16 KiB head + 4 KiB tail. The head
336 /// and tail windows never overlap ([`head_tail_windows`]).
337 const HYBRID_HEAD_BYTES: usize = 32 * 1024;
338 const HYBRID_TAIL_BYTES: usize = 8 * 1024;
339 const HANDLE_ONLY_HEAD_BYTES: usize = 16 * 1024;
340 const HANDLE_ONLY_TAIL_BYTES: usize = 4 * 1024;
341
342 /// Phrase used only by the TUI expand affordance and the UI-side detection of
343 /// historical truncated previews. Never emitted into model-facing content:
344 /// the model cannot open the tool details view, so the model-facing footer
345 /// carries the artifact path and a recovery instruction instead.
346 pub const SPILLOVER_PREVIEW_HINT: &str = "view full output in the tool details view";
347
348 /// Sentinel phrase the TUI matches on to recognise a current-format truncated
349 /// preview. It must stay a literal substring of every footer variant.
350 pub const SPILLOVER_RECOVERY_HINT: &str = "omitted range recovery:";
351
352 /// Model-facing recovery instruction for a truncated tool result.
353 ///
354 /// The previous text — "read it back with the read_file tool or with sed line
355 /// ranges" — named `read_file`, which is not model-visible at all (only `File`
356 /// is), and otherwise leaned on reaching the artifact by path. Reaching it by
357 /// path is *conditional*: `ToolContext::resolve_path` short-circuits under
358 /// trust mode, so `File action="read"` on an artifact under
359 /// `~/.codewhale/sessions/` succeeds in a trusted/auto session and is refused
360 /// as a path escape otherwise — and even when it succeeds it pages the file
361 /// rather than seeking the omitted range. Meanwhile `retrieve_tool_result` —
362 /// model-visible, purpose-built, unconditional, and already named correctly by
363 /// the web overflow path in `tools/web/overflow.rs` — went unmentioned.
364 /// `tests/adaptive_evidence_acceptance.rs` proves end to end that a model
365 /// handed one of these receipts can take the named ref and get the omitted
366 /// bytes back.
367 ///
368 /// The distinction that matters is retrievability, not tidiness. An adaptive
369 /// session artifact carries an `art_<id>` the retrieval tool resolves, so name
370 /// it. A legacy global spillover is authorized by an ownership sidecar whose
371 /// write is allowed to fail (see [`publish_legacy_spillover_ownership`]), so
372 /// promising retrieval there would just be a fourth dead route; say plainly
373 /// that there is no tool call for it and name what does work instead.
374 fn spillover_recovery_instruction(retrieval_ref: Option<&str>) -> String {
375 match retrieval_ref {
376 Some(reference) => format!(
377 "{SPILLOVER_RECOVERY_HINT} call retrieve_tool_result with ref=\"{reference}\" \
378 (mode=\"tail\" for the end, mode=\"lines\" with lines=\"120-160\" for a range, \
379 mode=\"query\" with query=\"…\" to search it)"
380 ),
381 None => format!(
382 "{SPILLOVER_RECOVERY_HINT} no tool call reaches this copy — re-run the command \
383 with narrower output (a tighter filter, or head/tail) if you need the rest"
384 ),
385 }
386 }
387
388 /// Model-facing footer for a truncated tool result. Names how much was
389 /// omitted (bytes and lines), where the complete output lives on disk, and
390 /// how the model can read the omitted range back.
391 fn spillover_preview_footer(
392 omitted_bytes: usize,
393 omitted_lines: usize,
394 recovery_path: &str,
395 retrieval_ref: Option<&str>,
396 ) -> String {
397 format!(
398 "… {} of output omitted ({omitted_lines} lines) — full output at {recovery_path}; {}",
399 crate::artifacts::format_byte_size(omitted_bytes.try_into().unwrap_or(u64::MAX)),
400 spillover_recovery_instruction(retrieval_ref)
401 )
402 }
403
404 /// Split `content` into a head of at most `head_bytes` and a tail of at most
405 /// `tail_bytes` that never overlap: the tail window always starts at or after
406 /// the head window ends, so no byte of the output appears twice and the
407 /// omitted count is exact.
408 fn head_tail_windows(content: &str, head_bytes: usize, tail_bytes: usize) -> (&str, &str) {
409 let head_end = (0..=head_bytes.min(content.len()))
410 .rev()
411 .find(|&index| content.is_char_boundary(index))
412 .unwrap_or(0);
413 let tail_floor = content.len().saturating_sub(tail_bytes).max(head_end);
414 let tail_start = (tail_floor..=content.len())
415 .find(|&index| content.is_char_boundary(index))
416 .unwrap_or(content.len());
417 (&content[..head_end], &content[tail_start..])
418 }
419
420 /// Build the model-facing preview for a truncated tool result: the head, an
421 /// honest footer naming how much was omitted and where the full output can be
422 /// read back, and a short retained tail. When the head and tail windows cover
423 /// the whole output (nothing was actually omitted), the content is returned
424 /// unchanged — the preview never claims a truncation that did not happen.
425 fn truncated_preview(
426 head: &str,
427 tail: &str,
428 original: &str,
429 recovery_path: &str,
430 retrieval_ref: Option<&str>,
431 ) -> String {
432 let omitted = original.len().saturating_sub(head.len() + tail.len());
433 if omitted == 0 {
434 return original.to_string();
435 }
436 let omitted_lines = original[head.len()..original.len() - tail.len()]
437 .lines()
438 .count();
439 format!(
440 "{head}\n\n{}\n\n…\n{tail}",
441 spillover_preview_footer(omitted, omitted_lines, recovery_path, retrieval_ref)
442 )
443 }
444
445 /// Apply spillover to a tool result in place. If the result's
446 /// content exceeds [`SPILLOVER_THRESHOLD_BYTES`], writes the full
447 /// content to a sibling file under `~/.codewhale/tool_outputs/`,
448 /// replaces `result.content` with a [`SPILLOVER_HEAD_BYTES`] head
449 /// plus a footer naming the spillover path and how to read the
450 /// omitted range back, and stamps `metadata.spillover_path` so the
451 /// UI can render its expand annotation.
452 ///
453 /// Returns the spillover path on success, `None` if no spillover
454 /// happened (content small enough, error result, write failure).
455 /// Failures are logged but never bubble up — a tool that produced a
456 /// result shouldn't be marked failed because the spillover writer
457 /// couldn't reach disk; we degrade to no-op and the model gets the
458 /// original (large) content.
459 ///
460 /// Error results (`success == false`) are skipped: error messages
461 /// are typically short, and turning them into a truncated preview
462 /// would just hide the error from the model's reasoning.
463 #[allow(dead_code)]
464 pub fn apply_spillover(result: &mut ToolResult, tool_id: &str) -> Option<PathBuf> {
465 apply_spillover_inner(result, tool_id, None)
466 }
467
468 /// Apply adaptive routing and publish session-scoped exact evidence.
469 ///
470 /// The default path writes one immutable payload under the origin session and
471 /// replaces non-inline content with a bounded preview whose footer names the
472 /// artifact path and how to read the omitted range back. The legacy dual
473 /// spillover behavior is reachable only through the classic rollback switch.
474 pub fn apply_spillover_with_artifact(
475 result: &mut ToolResult,
476 tool_id: &str,
477 tool_name: &str,
478 session_id: &str,
479 ) -> Option<PathBuf> {
480 // Registry discovery intentionally sends the complete eligible catalog to
481 // the model for semantic selection. Spilling it here would replace most of
482 // that candidate set with an artifact pointer before context shaping gets
483 // a chance to preserve it.
484 if tool_name == "registry_sync" {
485 return None;
486 }
487 apply_spillover_inner(
488 result,
489 tool_id,
490 Some(ArtifactSpilloverContext {
491 tool_name,
492 session_id,
493 }),
494 )
495 }
496
497 #[derive(Clone, Copy)]
498 struct ArtifactSpilloverContext<'a> {
499 tool_name: &'a str,
500 session_id: &'a str,
501 }
502
503 fn apply_spillover_inner(
504 result: &mut ToolResult,
505 tool_id: &str,
506 artifact_context: Option<ArtifactSpilloverContext<'_>>,
507 ) -> Option<PathBuf> {
508 if !crate::tools::large_output_router::classic_output_routing_enabled()
509 && let Some(context) = artifact_context
510 {
511 return apply_adaptive_evidence_inner(result, tool_id, context);
512 }
513 if !result.success {
514 return None;
515 }
516 if result.content.len() <= SPILLOVER_THRESHOLD_BYTES {
517 return None;
518 }
519 let original_content = result.content.clone();
520 let outcome = match maybe_spillover(
521 tool_id,
522 &original_content,
523 SPILLOVER_THRESHOLD_BYTES,
524 SPILLOVER_HEAD_BYTES,
525 ) {
526 Ok(Some(pair)) => pair,
527 Ok(None) => return None,
528 Err(err) => {
529 tracing::warn!(
530 target: "spillover",
531 ?err,
532 tool_id,
533 "spillover write failed; passing original content through"
534 );
535 return None;
536 }
537 };
538 let (_head, path) = outcome;
539 let (head, tail) = head_tail_windows(
540 &original_content,
541 SPILLOVER_HEAD_BYTES,
542 SPILLOVER_TAIL_BYTES,
543 );
544 let digest = crate::hashing::sha256_hex(original_content.as_bytes());
545 let path_str = path.display().to_string();
546
547 // Keep publishing the legacy ownership proof even though the model-facing
548 // footer no longer mentions retrieval: the tool-details pager authorizes
549 // legacy spillover reads through this sidecar.
550 if let Some(context) = artifact_context
551 && let Err(err) = publish_legacy_spillover_ownership(
552 &path,
553 context.session_id,
554 original_content.as_bytes(),
555 )
556 {
557 tracing::warn!(
558 target: "spillover",
559 ?err,
560 tool_id,
561 "legacy spillover ownership publication failed"
562 );
563 }
564
565 let mut artifact_path = None;
566 if let Some(context) = artifact_context {
567 let artifact_id = crate::artifacts::artifact_id_for_tool_call(tool_id);
568 match crate::artifacts::write_session_artifact(
569 context.session_id,
570 &artifact_id,
571 &original_content,
572 ) {
573 Ok((absolute_path, relative_path)) => {
574 let record = crate::artifacts::record_tool_output_artifact(
575 context.session_id,
576 tool_id,
577 context.tool_name,
578 relative_path.clone(),
579 &original_content,
580 );
581 result.content = truncated_preview(
582 head,
583 tail,
584 &original_content,
585 &absolute_path.display().to_string(),
586 Some(artifact_id.as_str()),
587 );
588 artifact_path = Some((absolute_path, relative_path, record));
589 }
590 Err(err) => {
591 tracing::warn!(
592 target: "spillover",
593 ?err,
594 tool_id,
595 "session artifact write failed; falling back to legacy spillover footer"
596 );
597 }
598 }
599 }
600
601 if artifact_path.is_none() {
602 // Legacy fallback: no session artifact was written, so there is no
603 // `art_<id>` ref to hand over — only the on-disk path.
604 result.content = truncated_preview(head, tail, &original_content, &path_str, None);
605 }
606
607 let metadata = result.metadata.get_or_insert_with(|| serde_json::json!({}));
608 if let Some(obj) = metadata.as_object_mut() {
609 if let Some((absolute_path, relative_path, record)) = artifact_path.as_ref() {
610 obj.insert(
611 "spillover_path".into(),
612 serde_json::Value::String(absolute_path.display().to_string()),
613 );
614 obj.insert(
615 "legacy_spillover_path".into(),
616 serde_json::Value::String(path_str),
617 );
618 obj.insert(
619 "artifact_id".into(),
620 serde_json::Value::String(record.id.clone()),
621 );
622 obj.insert(
623 "artifact_session_id".into(),
624 serde_json::Value::String(record.session_id.clone()),
625 );
626 obj.insert(
627 "artifact_relative_path".into(),
628 serde_json::Value::String(crate::artifacts::format_artifact_relative_path(
629 relative_path,
630 )),
631 );
632 obj.insert(
633 "artifact_path".into(),
634 serde_json::Value::String(absolute_path.display().to_string()),
635 );
636 obj.insert(
637 "artifact_byte_size".into(),
638 serde_json::Value::Number(serde_json::Number::from(record.byte_size)),
639 );
640 obj.insert(
641 "artifact_preview".into(),
642 serde_json::Value::String(record.preview.clone()),
643 );
644 } else {
645 obj.insert("spillover_path".into(), serde_json::Value::String(path_str));
646 }
647 } else {
648 // Pre-existing metadata that wasn't a JSON object (rare,
649 // possibly an array). Replace with an object so we can
650 // attach our key without losing prior data — wrap it under
651 // a `_prior` field so callers that introspect can recover.
652 let prior = std::mem::replace(metadata, serde_json::json!({}));
653 if let Some(obj) = metadata.as_object_mut() {
654 obj.insert("_prior".into(), prior);
655 if let Some((absolute_path, relative_path, record)) = artifact_path.as_ref() {
656 obj.insert(
657 "spillover_path".into(),
658 serde_json::Value::String(absolute_path.display().to_string()),
659 );
660 obj.insert(
661 "legacy_spillover_path".into(),
662 serde_json::Value::String(path.display().to_string()),
663 );
664 obj.insert(
665 "artifact_id".into(),
666 serde_json::Value::String(record.id.clone()),
667 );
668 obj.insert(
669 "artifact_session_id".into(),
670 serde_json::Value::String(record.session_id.clone()),
671 );
672 obj.insert(
673 "artifact_relative_path".into(),
674 serde_json::Value::String(crate::artifacts::format_artifact_relative_path(
675 relative_path,
676 )),
677 );
678 obj.insert(
679 "artifact_path".into(),
680 serde_json::Value::String(absolute_path.display().to_string()),
681 );
682 obj.insert(
683 "artifact_byte_size".into(),
684 serde_json::Value::Number(serde_json::Number::from(record.byte_size)),
685 );
686 obj.insert(
687 "artifact_preview".into(),
688 serde_json::Value::String(record.preview.clone()),
689 );
690 } else {
691 obj.insert(
692 "spillover_path".into(),
693 serde_json::Value::String(path.display().to_string()),
694 );
695 }
696 }
697 }
698 if let Some(obj) = result
699 .metadata
700 .as_mut()
701 .and_then(serde_json::Value::as_object_mut)
702 {
703 obj.insert("truncated".into(), serde_json::Value::Bool(true));
704 obj.insert(
705 "content_digest".into(),
706 serde_json::Value::String(format!("sha256:{digest}")),
707 );
708 obj.insert(
709 "original_byte_count".into(),
710 serde_json::Value::Number(serde_json::Number::from(original_content.len() as u64)),
711 );
712 obj.insert(
713 "retained_head_bytes".into(),
714 serde_json::Value::Number(serde_json::Number::from(head.len() as u64)),
715 );
716 obj.insert(
717 "retained_tail_bytes".into(),
718 serde_json::Value::Number(serde_json::Number::from(tail.len() as u64)),
719 );
720 }
721 artifact_path
722 .map(|(absolute_path, _, _)| absolute_path)
723 .or(Some(path))
724 }
725
726 fn apply_adaptive_evidence_inner(
727 result: &mut ToolResult,
728 tool_id: &str,
729 context: ArtifactSpilloverContext<'_>,
730 ) -> Option<PathBuf> {
731 use crate::tools::large_output_router::{
732 DEFAULT_LARGE_OUTPUT_THRESHOLD_TOKENS, EVIDENCE_RETENTION_SECS, EvidenceArtifact,
733 EvidenceRetentionState, EvidenceRouting, estimate_tokens, publish_evidence_metadata,
734 unix_millis_now,
735 };
736
737 let estimated_tokens = estimate_tokens(&result.content);
738 let threshold = result
739 .metadata
740 .as_ref()
741 .and_then(|metadata| metadata.get("evidence_threshold_tokens"))
742 .and_then(serde_json::Value::as_u64)
743 .and_then(|value| usize::try_from(value).ok())
744 .unwrap_or(DEFAULT_LARGE_OUTPUT_THRESHOLD_TOKENS);
745 let routing = result
746 .metadata
747 .as_ref()
748 .and_then(|metadata| metadata.get("evidence_routing"))
749 .cloned()
750 .and_then(|value| serde_json::from_value::<EvidenceRouting>(value).ok())
751 .unwrap_or_else(|| EvidenceRouting::from_token_estimate(estimated_tokens, threshold));
752 if routing == EvidenceRouting::Inline {
753 return None;
754 }
755
756 let original = result.content.clone();
757 let (head_bytes, tail_bytes) = if routing == EvidenceRouting::Hybrid {
758 (HYBRID_HEAD_BYTES, HYBRID_TAIL_BYTES)
759 } else {
760 (HANDLE_ONLY_HEAD_BYTES, HANDLE_ONLY_TAIL_BYTES)
761 };
762 let (head, tail) = head_tail_windows(&original, head_bytes, tail_bytes);
763 let omitted = original.len().saturating_sub(head.len() + tail.len());
764 if omitted == 0 {
765 // The whole output fits inside the preview budget: there is nothing
766 // to recover, so publishing an artifact and claiming a truncation
767 // would both be dishonest. Pass the content through unchanged.
768 return None;
769 }
770 let head_len = head.len();
771 let tail_len = tail.len();
772
773 let artifact_id = crate::artifacts::artifact_id_for_tool_call(tool_id);
774 let relative_path = crate::artifacts::session_artifact_relative_path(&artifact_id);
775 let digest = crate::hashing::sha256_hex(original.as_bytes());
776 let now_ms = unix_millis_now();
777 let proposed_artifact = EvidenceArtifact {
778 handle: artifact_id.clone(),
779 digest: digest.clone(),
780 size_bytes: original.len().try_into().unwrap_or(u64::MAX),
781 content_type: if serde_json::from_str::<serde_json::Value>(&original).is_ok() {
782 "application/json".to_string()
783 } else {
784 "text/plain".to_string()
785 },
786 tool_name: context.tool_name.to_string(),
787 call_id: tool_id.to_string(),
788 origin_session: context.session_id.to_string(),
789 generation: 1,
790 redacted: false,
791 encoding: "utf-8".to_string(),
792 retention_state: EvidenceRetentionState::Live,
793 created_at_unix_ms: now_ms,
794 retain_until_unix_ms: now_ms.saturating_add(EVIDENCE_RETENTION_SECS * 1_000),
795 storage_path: relative_path.clone(),
796 };
797 let artifact = match crate::tools::large_output_router::read_evidence_metadata(
798 context.session_id,
799 &artifact_id,
800 ) {
801 Ok(existing)
802 if existing.digest == proposed_artifact.digest
803 && existing.size_bytes == proposed_artifact.size_bytes
804 && existing.call_id == proposed_artifact.call_id
805 && existing.origin_session == proposed_artifact.origin_session =>
806 {
807 existing
808 }
809 Ok(_) => {
810 tracing::warn!(target: "evidence", tool_id, "adaptive evidence replay conflicts with immutable metadata");
811 return None;
812 }
813 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
814 if let Err(err) = publish_evidence_metadata(context.session_id, &proposed_artifact) {
815 tracing::warn!(target: "evidence", ?err, tool_id, "adaptive evidence metadata publication failed");
816 return None;
817 }
818 proposed_artifact
819 }
820 Err(err) => {
821 tracing::warn!(target: "evidence", ?err, tool_id, "adaptive evidence metadata validation failed");
822 return None;
823 }
824 };
825
826 // Seal the ownership/integrity record before publishing predictable
827 // `art_<call>.txt` bytes. If metadata publication fails, no payload exists
828 // for a guessed handle to retrieve without the generation, redaction,
829 // retention, size, and digest checks above. A metadata-only interruption
830 // is safe: the handle is never advertised and a retry can idempotently
831 // publish the matching bytes.
832 let (absolute_path, relative_path) = match crate::artifacts::write_session_artifact_immutable(
833 context.session_id,
834 &artifact_id,
835 original.as_bytes(),
836 ) {
837 Ok(paths) => paths,
838 Err(err) => {
839 tracing::warn!(target: "evidence", ?err, tool_id, "adaptive evidence content publication failed");
840 return None;
841 }
842 };
843
844 let record = crate::artifacts::record_tool_output_artifact(
845 context.session_id,
846 tool_id,
847 context.tool_name,
848 relative_path.clone(),
849 &original,
850 );
851 result.content = truncated_preview(
852 head,
853 tail,
854 &original,
855 &absolute_path.display().to_string(),
856 Some(artifact_id.as_str()),
857 );
858 let metadata = result.metadata.get_or_insert_with(|| serde_json::json!({}));
859 if let Some(object) = metadata.as_object_mut() {
860 object.insert(
861 "spillover_path".into(),
862 absolute_path.display().to_string().into(),
863 );
864 object.insert("artifact_id".into(), artifact_id.into());
865 object.insert("artifact_session_id".into(), context.session_id.into());
866 object.insert(
867 "artifact_relative_path".into(),
868 crate::artifacts::format_artifact_relative_path(&relative_path).into(),
869 );
870 object.insert("artifact_byte_size".into(), artifact.size_bytes.into());
871 object.insert("artifact_digest".into(), digest.into());
872 object.insert("artifact_generation".into(), artifact.generation.into());
873 object.insert("artifact_encoding".into(), artifact.encoding.into());
874 object.insert("artifact_retention_state".into(), "live".into());
875 object.insert("evidence_available".into(), true.into());
876 object.insert("truncated".into(), true.into());
877 object.insert("original_byte_count".into(), artifact.size_bytes.into());
878 object.insert("retained_head_bytes".into(), head_len.into());
879 object.insert("retained_tail_bytes".into(), tail_len.into());
880 object.insert(
881 "artifact_preview".into(),
882 original.chars().take(200).collect::<String>().into(),
883 );
884 object.insert(
885 "artifact_record".into(),
886 serde_json::to_value(record).unwrap_or(serde_json::Value::Null),
887 );
888 }
889 Some(absolute_path)
890 }
891
892 /// Sanitise a tool call id for use as a filename. Keeps ASCII
893 /// alphanumerics, `-`, and `_`; rejects `.` to keep `..` traversal
894 /// out, rejects empty results. Returns `None` if the input contains
895 /// no acceptable characters.
896 fn sanitise_id(id: &str) -> Option<String> {
897 let cleaned: String = id
898 .chars()
899 .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
900 .collect();
901 if cleaned.is_empty() {
902 None
903 } else {
904 Some(cleaned)
905 }
906 }
907
908 /// Override the storage roots for tests so they don't pollute the
909 /// user's real `~/.codewhale/` directory. This uses explicit test hooks instead
910 /// of `$HOME` because Windows home-dir resolution can ignore environment
911 /// overrides and return the runner profile directory.
912 #[cfg(test)]
913 fn with_test_home<F, R>(home: &Path, f: F) -> R
914 where
915 F: FnOnce() -> R,
916 {
917 let _artifact_guard = crate::artifacts::TEST_ARTIFACT_SESSIONS_GUARD
918 .lock()
919 .unwrap_or_else(|err| err.into_inner());
920
921 struct StorageRootOverride {
922 prior_spillover: Option<PathBuf>,
923 prior_artifacts: Option<PathBuf>,
924 }
925
926 impl Drop for StorageRootOverride {
927 fn drop(&mut self) {
928 set_test_spillover_root(self.prior_spillover.take());
929 crate::artifacts::set_test_artifact_sessions_root(self.prior_artifacts.take());
930 }
931 }
932
933 // Tests in this module serialize spillover through `TEST_GUARD`; the
934 // artifact guard above protects the session-artifact root shared with
935 // artifacts.rs tests.
936 let prior_spillover =
937 set_test_spillover_root(Some(home.join(".codewhale").join(SPILLOVER_DIR_NAME)));
938 let prior_artifacts = crate::artifacts::set_test_artifact_sessions_root(Some(
939 home.join(".codewhale").join("sessions"),
940 ));
941 let _restore = StorageRootOverride {
942 prior_spillover,
943 prior_artifacts,
944 };
945 f()
946 }
947
948 #[cfg(test)]
949 mod tests {
950 use super::*;
951 use tempfile::tempdir;
952
953 /// Tests in this module serialize through this guard because they mutate
954 /// process-global test storage roots. Without it, cargo's parallel runner
955 /// would observe interleaved overrides.
956 fn setup() -> std::sync::MutexGuard<'static, ()> {
957 super::TEST_SPILLOVER_GUARD
958 .lock()
959 .unwrap_or_else(|e| e.into_inner())
960 }
961
962 /// The old hint named `read_file`, which is not registered for the model
963 /// at all, and otherwise pointed at routes that only reach the artifact
964 /// under trust mode (see [`spillover_recovery_instruction`]), while never
965 /// naming `retrieve_tool_result` — model-visible, unconditional, and built
966 /// for exactly this.
967 #[test]
968 fn truncation_footer_names_a_recovery_route_that_works() {
969 let footer = spillover_preview_footer(
970 4096,
971 120,
972 "/tmp/artifacts/art_call-1.txt",
973 Some("art_call-1"),
974 );
975
976 assert!(footer.contains("retrieve_tool_result"), "{footer}");
977 assert!(footer.contains("ref=\"art_call-1\""), "{footer}");
978 assert!(!footer.contains("read_file"), "{footer}");
979 assert!(!footer.contains("sed"), "{footer}");
980 }
981
982 /// The legacy global copy has no guaranteed authorization sidecar, so the
983 /// footer must not invent a fourth dead route — but it still must not name
984 /// the three it used to.
985 #[test]
986 fn truncation_footer_without_an_artifact_promises_nothing_it_cannot_deliver() {
987 let footer = spillover_preview_footer(4096, 120, "/tmp/tool_outputs/call-1.txt", None);
988
989 assert!(
990 footer.contains("no tool call reaches this copy"),
991 "{footer}"
992 );
993 assert!(!footer.contains("retrieve_tool_result"), "{footer}");
994 assert!(!footer.contains("read_file"), "{footer}");
995 assert!(!footer.contains("sed"), "{footer}");
996 }
997
998 /// The TUI keys its "this preview was truncated" detection off the shared
999 /// constant, so it has to stay a literal substring of every variant.
1000 #[test]
1001 fn every_footer_variant_carries_the_ui_detection_marker() {
1002 for reference in [Some("art_call-1"), None] {
1003 let footer = spillover_preview_footer(4096, 120, "/tmp/x.txt", reference);
1004 assert!(footer.contains(SPILLOVER_RECOVERY_HINT), "{footer}");
1005 }
1006 }
1007
1008 #[test]
1009 fn with_test_home_overrides_storage_roots_without_home_resolution() {
1010 let _g = setup();
1011 let tmp = tempdir().unwrap();
1012
1013 with_test_home(tmp.path(), || {
1014 assert_eq!(
1015 spillover_root().as_deref(),
1016 Some(tmp.path().join(".codewhale").join("tool_outputs").as_path())
1017 );
1018 assert_eq!(
1019 crate::artifacts::session_artifact_absolute_path(
1020 "session-123",
1021 &PathBuf::from("artifacts").join("art_call-big.txt")
1022 )
1023 .as_deref(),
1024 Some(
1025 tmp.path()
1026 .join(".codewhale")
1027 .join("sessions")
1028 .join("session-123")
1029 .join("artifacts")
1030 .join("art_call-big.txt")
1031 .as_path()
1032 )
1033 );
1034 });
1035 }
1036
1037 #[test]
1038 fn sanitise_id_keeps_safe_chars_and_drops_dangerous() {
1039 assert_eq!(super::sanitise_id("abc-123_x"), Some("abc-123_x".into()));
1040 // `.` is dropped to keep `..` out of the path.
1041 assert_eq!(super::sanitise_id("../etc"), Some("etc".into()));
1042 assert_eq!(super::sanitise_id("/etc/passwd"), Some("etcpasswd".into()));
1043 // Empty-after-sanitise → None.
1044 assert!(super::sanitise_id("...").is_none());
1045 assert!(super::sanitise_id("").is_none());
1046 }
1047
1048 #[test]
1049 fn write_spillover_creates_directory_and_writes_file() {
1050 let _g = setup();
1051 let tmp = tempdir().unwrap();
1052 with_test_home(tmp.path(), || {
1053 let path = write_spillover("call-abc", "hello world").expect("write");
1054 assert!(path.exists(), "{path:?} missing");
1055 let body = fs::read_to_string(&path).unwrap();
1056 assert_eq!(body, "hello world");
1057 // Directory landed under `<HOME>/.codewhale/tool_outputs/`.
1058 // Compare components instead of a substring on `to_string_lossy`
1059 // — Windows uses `\` as the separator so a `/` substring match
1060 // would falsely fail there.
1061 let components: Vec<&str> = path
1062 .components()
1063 .filter_map(|c| c.as_os_str().to_str())
1064 .collect();
1065 assert!(
1066 components.contains(&".codewhale") && components.contains(&"tool_outputs"),
1067 "spillover path missing expected `.codewhale/tool_outputs/...` segments: {path:?}"
1068 );
1069 });
1070 }
1071
1072 #[test]
1073 fn write_spillover_rejects_empty_id() {
1074 let _g = setup();
1075 let tmp = tempdir().unwrap();
1076 with_test_home(tmp.path(), || {
1077 let err = write_spillover("...", "x").unwrap_err();
1078 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
1079 });
1080 }
1081
1082 #[test]
1083 fn maybe_spillover_returns_none_below_threshold() {
1084 let _g = setup();
1085 let tmp = tempdir().unwrap();
1086 with_test_home(tmp.path(), || {
1087 let out = maybe_spillover("call-1", "tiny content", 100 * 1024, 4 * 1024).expect("ok");
1088 assert!(out.is_none());
1089 });
1090 }
1091
1092 #[test]
1093 fn maybe_spillover_writes_and_returns_head_above_threshold() {
1094 let _g = setup();
1095 let tmp = tempdir().unwrap();
1096 with_test_home(tmp.path(), || {
1097 // Content larger than the threshold.
1098 let big = "A".repeat(2_000);
1099 let (head, path) = maybe_spillover("call-2", &big, 1_000, 256)
1100 .expect("ok")
1101 .expect("should have spilled");
1102 // Head is bounded.
1103 assert_eq!(head.len(), 256);
1104 // Full content on disk.
1105 let body = fs::read_to_string(&path).unwrap();
1106 assert_eq!(body.len(), 2_000);
1107 });
1108 }
1109
1110 #[test]
1111 fn maybe_spillover_does_not_split_inside_a_codepoint() {
1112 let _g = setup();
1113 let tmp = tempdir().unwrap();
1114 with_test_home(tmp.path(), || {
1115 // 4 byte chars; ask for 3 bytes of head → walks back to
1116 // the previous char boundary (0).
1117 let s = "🐳🐳🐳🐳"; // 4 × 4-byte codepoints
1118 assert_eq!(s.len(), 16);
1119 let (head, _) = maybe_spillover("call-3", s, 1, 3)
1120 .expect("ok")
1121 .expect("spilled");
1122 // 3 isn't a char boundary in this string; walk back → 0.
1123 assert_eq!(head, "");
1124 // Asking for 4 bytes lands on the first char boundary.
1125 let (head, _) = maybe_spillover("call-3b", s, 1, 4)
1126 .expect("ok")
1127 .expect("spilled");
1128 assert_eq!(head, "🐳");
1129 });
1130 }
1131
1132 #[test]
1133 fn prune_older_than_handles_missing_root() {
1134 let _g = setup();
1135 let tmp = tempdir().unwrap();
1136 with_test_home(tmp.path(), || {
1137 // Nothing has ever written; root doesn't exist; that's fine.
1138 let count = prune_older_than(SPILLOVER_MAX_AGE).expect("ok");
1139 assert_eq!(count, 0);
1140 });
1141 }
1142
1143 // The mtime backdate uses utimensat (Unix-only). On Windows the
1144 // filetime_set_modified helper is a no-op, so the prune wouldn't see
1145 // any stale files. Gate the whole test on `cfg(unix)` instead of
1146 // testing a no-op path that can't fail meaningfully.
1147 #[test]
1148 #[cfg(unix)]
1149 fn prune_older_than_keeps_fresh_files_drops_stale_ones() {
1150 let _g = setup();
1151 let tmp = tempdir().unwrap();
1152 with_test_home(tmp.path(), || {
1153 let fresh = write_spillover("fresh", "x").unwrap();
1154 let stale = write_spillover("stale", "y").unwrap();
1155
1156 // Backdate `stale` to 30 days ago.
1157 let thirty_days = SystemTime::now() - Duration::from_secs(30 * 24 * 60 * 60);
1158 filetime_set_modified(&stale, thirty_days);
1159
1160 let pruned = prune_older_than(SPILLOVER_MAX_AGE).unwrap();
1161 assert_eq!(pruned, 1);
1162 assert!(fresh.exists());
1163 assert!(!stale.exists());
1164 });
1165 }
1166
1167 /// Set the mtime on a file. The workspace doesn't pull the
1168 /// `filetime` crate, so we reach for `utimensat` directly on
1169 /// Unix. Windows is a no-op — the prune semantics are the same
1170 /// and the per-cycle stress test lives on the Unix path.
1171 #[cfg(unix)]
1172 fn filetime_set_modified(path: &Path, when: SystemTime) {
1173 let secs = when
1174 .duration_since(SystemTime::UNIX_EPOCH)
1175 .unwrap_or_default()
1176 .as_secs() as libc::time_t;
1177 let times = [
1178 libc::timespec {
1179 tv_sec: secs,
1180 tv_nsec: 0,
1181 },
1182 libc::timespec {
1183 tv_sec: secs,
1184 tv_nsec: 0,
1185 },
1186 ];
1187 let path_c = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()).unwrap();
1188 // SAFETY: path_c is a valid CString; times is a 2-element array
1189 // matching utimensat's signature.
1190 let rc = unsafe { libc::utimensat(libc::AT_FDCWD, path_c.as_ptr(), times.as_ptr(), 0) };
1191 assert_eq!(
1192 rc,
1193 0,
1194 "utimensat failed: {}",
1195 std::io::Error::last_os_error()
1196 );
1197 }
1198
1199 // Windows stub removed in v0.8.8 — the only caller of
1200 // `filetime_set_modified` is `prune_older_than_keeps_fresh_files_drops_stale_ones`,
1201 // which is now `#[cfg(unix)]` because mtime backdating requires
1202 // `utimensat` and a Windows no-op stub can't make the assertion pass
1203 // anyway. Keeping the stub triggered `-D dead-code` on Windows builds
1204 // (the prune test was the only caller) and broke `Test (windows-latest)`.
1205
1206 #[test]
1207 fn apply_spillover_is_noop_below_threshold() {
1208 let _g = setup();
1209 let tmp = tempdir().unwrap();
1210 with_test_home(tmp.path(), || {
1211 let mut result = ToolResult::success("small payload");
1212 let path = apply_spillover(&mut result, "call-small");
1213 assert!(path.is_none());
1214 assert_eq!(result.content, "small payload");
1215 assert!(result.metadata.is_none());
1216 });
1217 }
1218
1219 #[test]
1220 fn apply_spillover_is_noop_for_error_results() {
1221 let _g = setup();
1222 let tmp = tempdir().unwrap();
1223 with_test_home(tmp.path(), || {
1224 // Even very large error messages are passed through —
1225 // truncating an error would hide it from the model.
1226 let big_err = "boom\n".repeat(50_000);
1227 let mut result = ToolResult::error(big_err.clone());
1228 let path = apply_spillover(&mut result, "call-err");
1229 assert!(path.is_none());
1230 assert_eq!(result.content, big_err);
1231 });
1232 }
1233
1234 #[test]
1235 fn apply_spillover_truncates_and_stamps_metadata_above_threshold() {
1236 let _g = setup();
1237 let tmp = tempdir().unwrap();
1238 with_test_home(tmp.path(), || {
1239 // 200 KiB body — well above the 100 KiB threshold.
1240 let big = "X".repeat(200 * 1024);
1241 let mut result = ToolResult::success(big.clone());
1242 let path = apply_spillover(&mut result, "call-big").expect("should spill");
1243
1244 // Inline content shrunk to head + honest preview footer.
1245 assert!(result.content.len() < big.len());
1246 assert!(
1247 !result.content.contains(SPILLOVER_PREVIEW_HINT),
1248 "the tool-details phrase is a UI affordance, not model-facing"
1249 );
1250 assert!(
1251 result.content.contains("of output omitted"),
1252 "footer missing: {}",
1253 &result.content[result.content.len().saturating_sub(200)..]
1254 );
1255 // The footer tells the model where the full output lives and how
1256 // to read the omitted range back.
1257 assert!(result.content.contains("full output at"));
1258 assert!(result.content.contains(&path.display().to_string()));
1259 assert!(result.content.contains(SPILLOVER_RECOVERY_HINT));
1260 assert!(
1261 !result.content.contains("retrieve_tool_result"),
1262 "legacy spillover ownership can fail to publish; promising \
1263 retrieval here would be another dead route"
1264 );
1265 assert!(!result.content.contains("read_file"));
1266 assert!(!result.content.contains("sed"));
1267
1268 // Full bytes are on disk at the returned path.
1269 assert!(path.exists(), "spillover file missing: {path:?}");
1270 let body = fs::read_to_string(&path).unwrap();
1271 assert_eq!(body.len(), 200 * 1024);
1272
1273 // metadata.spillover_path stamped for the UI to find.
1274 let metadata = result.metadata.expect("metadata stamped");
1275 let stamped = metadata
1276 .get("spillover_path")
1277 .and_then(serde_json::Value::as_str)
1278 .expect("spillover_path key present");
1279 assert_eq!(stamped, path.display().to_string());
1280 assert_eq!(metadata["truncated"], true);
1281 assert_eq!(metadata["original_byte_count"], 200 * 1024);
1282 assert_eq!(metadata["retained_head_bytes"], SPILLOVER_HEAD_BYTES);
1283 assert_eq!(metadata["retained_tail_bytes"], SPILLOVER_TAIL_BYTES);
1284 assert!(
1285 metadata["content_digest"]
1286 .as_str()
1287 .is_some_and(|digest| digest.starts_with("sha256:"))
1288 );
1289 });
1290 }
1291
1292 #[test]
1293 fn apply_spillover_with_artifact_writes_session_file_and_plain_preview() {
1294 let _g = setup();
1295 let tmp = tempdir().unwrap();
1296 with_test_home(tmp.path(), || {
1297 let big = "checking crate ... error[E0425]: cannot find value\n".repeat(4_000);
1298 let mut result = ToolResult::success(big.clone());
1299 let path =
1300 apply_spillover_with_artifact(&mut result, "call-big", "exec_shell", "session-123")
1301 .expect("should spill");
1302
1303 let session_artifact = tmp
1304 .path()
1305 .join(".codewhale")
1306 .join("sessions")
1307 .join("session-123")
1308 .join("artifacts")
1309 .join("art_call-big.txt");
1310 assert_eq!(path, session_artifact);
1311 assert_eq!(fs::read_to_string(&session_artifact).unwrap(), big);
1312 assert!(
1313 !tmp.path()
1314 .join(".codewhale/tool_outputs/call-big.txt")
1315 .exists(),
1316 "adaptive evidence stores one exact origin-session copy"
1317 );
1318 // The model sees a bounded preview with an honest footer: the
1319 // artifact path plus the retrieval call that actually resolves it.
1320 assert!(!result.content.contains(SPILLOVER_PREVIEW_HINT));
1321 assert!(result.content.contains("\n…\n"));
1322 assert!(result.content.contains("of output omitted"));
1323 assert!(result.content.contains("full output at"));
1324 assert!(result.content.contains(SPILLOVER_RECOVERY_HINT));
1325 assert!(
1326 result.content.contains("art_call-big.txt"),
1327 "footer must name the artifact path so the model can recover the output"
1328 );
1329 assert!(!result.content.contains("Exact evidence retained"));
1330 assert!(
1331 result.content.contains("retrieve_tool_result"),
1332 "a session artifact is retrievable; the footer must say so: {}",
1333 result.content
1334 );
1335 assert!(
1336 result.content.contains("ref=\"art_call-big\""),
1337 "the footer must hand over a ref that resolves: {}",
1338 result.content
1339 );
1340 assert!(
1341 session_artifact
1342 .with_file_name("art_call-big.evidence.json")
1343 .exists()
1344 );
1345
1346 let metadata = result.metadata.expect("metadata stamped");
1347 assert_eq!(
1348 metadata
1349 .get("artifact_id")
1350 .and_then(serde_json::Value::as_str),
1351 Some("art_call-big")
1352 );
1353 assert_eq!(
1354 metadata
1355 .get("artifact_relative_path")
1356 .and_then(serde_json::Value::as_str),
1357 Some("artifacts/art_call-big.txt")
1358 );
1359 assert_eq!(
1360 metadata
1361 .get("artifact_session_id")
1362 .and_then(serde_json::Value::as_str),
1363 Some("session-123")
1364 );
1365 assert_eq!(metadata["original_byte_count"], big.len());
1366 assert!(metadata["retained_head_bytes"].as_u64().unwrap_or(0) <= 16 * 1024);
1367 assert!(metadata["retained_tail_bytes"].as_u64().unwrap_or(0) <= 4 * 1024);
1368 });
1369 }
1370
1371 #[test]
1372 fn adaptive_evidence_keeps_success_and_failure_exact_distinct_and_out_of_context() {
1373 let _g = setup();
1374 let tmp = tempdir().unwrap();
1375 with_test_home(tmp.path(), || {
1376 let sentinel = "DEEP_RAW_SENTINEL";
1377 // Payloads must exceed the 32_768-token (≈96 KiB) handle-only
1378 // threshold so adaptive routing actually spills them.
1379 let success_raw = format!(
1380 "{}{}{}",
1381 "head\n".repeat(30_000),
1382 sentinel,
1383 "tail\n".repeat(30_000)
1384 );
1385 let failure_raw = format!("{}{}", "failure\n".repeat(30_000), "FAILURE_END");
1386 let mut success = ToolResult::success(success_raw.clone());
1387 let mut failure = ToolResult::error(failure_raw.clone());
1388
1389 let success_path = apply_spillover_with_artifact(
1390 &mut success,
1391 "call-success",
1392 "exec_shell",
1393 "session-a",
1394 )
1395 .expect("success evidence");
1396 let failure_path = apply_spillover_with_artifact(
1397 &mut failure,
1398 "call-failure",
1399 "mcp_fixture",
1400 "session-a",
1401 )
1402 .expect("failure evidence");
1403
1404 assert_ne!(success_path, failure_path);
1405 assert_eq!(
1406 std::fs::read(&success_path).unwrap(),
1407 success_raw.as_bytes()
1408 );
1409 assert_eq!(
1410 std::fs::read(&failure_path).unwrap(),
1411 failure_raw.as_bytes()
1412 );
1413 assert!(!success.content.contains(sentinel));
1414 // Handle-only preview: 16 KiB head + 4 KiB tail + footer.
1415 assert!(success.content.len() < 21 * 1024);
1416 let success_meta = success.metadata.as_ref().unwrap();
1417 let failure_meta = failure.metadata.as_ref().unwrap();
1418 assert_ne!(
1419 success_meta["artifact_digest"],
1420 failure_meta["artifact_digest"]
1421 );
1422 assert_eq!(success_meta["artifact_session_id"], "session-a");
1423 assert_eq!(failure_meta["artifact_session_id"], "session-a");
1424
1425 let mut replay = ToolResult::success(success_raw);
1426 let replay_path = apply_spillover_with_artifact(
1427 &mut replay,
1428 "call-success",
1429 "exec_shell",
1430 "session-a",
1431 )
1432 .expect("idempotent replay");
1433 assert_eq!(replay_path, success_path);
1434 });
1435 }
1436
1437 #[test]
1438 fn adaptive_evidence_publication_failure_emits_no_handle_or_details_hint() {
1439 let _g = setup();
1440 let tmp = tempdir().unwrap();
1441 with_test_home(tmp.path(), || {
1442 let session_dir = tmp
1443 .path()
1444 .join(".codewhale")
1445 .join("sessions")
1446 .join("session-blocked");
1447 std::fs::create_dir_all(&session_dir).unwrap();
1448 std::fs::write(session_dir.join("artifacts"), b"block artifact directory").unwrap();
1449
1450 let raw = format!(
1451 "{}{}{}",
1452 "publication failure head\n".repeat(1_500),
1453 "DEEP_FAILURE_SENTINEL",
1454 "publication failure tail\n".repeat(1_500),
1455 );
1456 let mut result = ToolResult::error(raw.clone());
1457 let path = apply_spillover_with_artifact(
1458 &mut result,
1459 "call-failed-publish",
1460 "mcp_fixture",
1461 "session-blocked",
1462 );
1463
1464 assert!(path.is_none());
1465 assert_eq!(result.content, raw);
1466 assert!(!result.content.contains(SPILLOVER_PREVIEW_HINT));
1467 assert!(!result.content.contains("retrieve_tool_result"));
1468 assert!(
1469 result
1470 .metadata
1471 .as_ref()
1472 .and_then(|metadata| metadata.get("evidence_available"))
1473 .is_none()
1474 );
1475 assert!(
1476 !session_dir
1477 .join("artifacts/art_call-failed-publish.txt")
1478 .exists()
1479 );
1480 });
1481 }
1482
1483 #[test]
1484 fn adaptive_evidence_metadata_atomic_failure_leaves_payload_unadvertised() {
1485 let _g = setup();
1486 let tmp = tempdir().unwrap();
1487 with_test_home(tmp.path(), || {
1488 let artifact_dir = tmp
1489 .path()
1490 .join(".codewhale")
1491 .join("sessions")
1492 .join("session-metadata-blocked")
1493 .join("artifacts");
1494 std::fs::create_dir_all(artifact_dir.join("art_call-failed-metadata.evidence.json"))
1495 .unwrap();
1496
1497 let raw = format!(
1498 "{}{}{}",
1499 "metadata failure head\n".repeat(1_500),
1500 "DEEP_METADATA_FAILURE_SENTINEL",
1501 "metadata failure tail\n".repeat(1_500),
1502 );
1503 let mut result = ToolResult::success(raw.clone());
1504 let path = apply_spillover_with_artifact(
1505 &mut result,
1506 "call-failed-metadata",
1507 "exec_shell",
1508 "session-metadata-blocked",
1509 );
1510
1511 assert!(path.is_none());
1512 assert_eq!(result.content, raw);
1513 assert!(!result.content.contains(SPILLOVER_PREVIEW_HINT));
1514 assert!(!result.content.contains("retrieve_tool_result"));
1515 assert!(
1516 !artifact_dir.join("art_call-failed-metadata.txt").exists(),
1517 "metadata failure must leave no payload behind a guessable handle"
1518 );
1519 });
1520 }
1521
1522 #[test]
1523 fn registry_catalog_is_never_spilled_out_of_model_context() {
1524 let original = "registry-entry\n".repeat(10_000);
1525 assert!(original.len() > SPILLOVER_THRESHOLD_BYTES);
1526 let mut result = ToolResult::success(original.clone());
1527
1528 let path = apply_spillover_with_artifact(
1529 &mut result,
1530 "call-registry",
1531 "registry_sync",
1532 "session-registry",
1533 );
1534
1535 assert!(path.is_none());
1536 assert_eq!(result.content, original);
1537 assert!(result.metadata.is_none());
1538 }
1539
1540 #[test]
1541 fn apply_spillover_preserves_existing_metadata() {
1542 let _g = setup();
1543 let tmp = tempdir().unwrap();
1544 with_test_home(tmp.path(), || {
1545 let big = "Y".repeat(200 * 1024);
1546 let mut result = ToolResult::success(big)
1547 .with_metadata(serde_json::json!({"prior_key": "prior_value"}));
1548 let path = apply_spillover(&mut result, "call-meta").expect("should spill");
1549
1550 let metadata = result.metadata.expect("metadata present");
1551 // Prior keys survive.
1552 assert_eq!(
1553 metadata
1554 .get("prior_key")
1555 .and_then(serde_json::Value::as_str),
1556 Some("prior_value")
1557 );
1558 // New key added alongside.
1559 assert_eq!(
1560 metadata
1561 .get("spillover_path")
1562 .and_then(serde_json::Value::as_str),
1563 Some(path.display().to_string().as_str())
1564 );
1565 });
1566 }
1567
1568 #[test]
1569 fn apply_spillover_wraps_non_object_metadata_under_prior_key() {
1570 // Defends against a tool whose `metadata` is something
1571 // other than a JSON object (rare — most use the `json!({})`
1572 // pattern — but legal per `serde_json::Value`). The
1573 // spillover writer must add `spillover_path` without losing
1574 // the prior payload.
1575 let _g = setup();
1576 let tmp = tempdir().unwrap();
1577 with_test_home(tmp.path(), || {
1578 let big = "Z".repeat(200 * 1024);
1579 let mut result = ToolResult::success(big).with_metadata(serde_json::json!([
1580 "unexpected",
1581 "array",
1582 "payload"
1583 ]));
1584 let path = apply_spillover(&mut result, "call-arr").expect("should spill");
1585
1586 let metadata = result.metadata.expect("metadata stamped");
1587 // Prior payload re-homed under `_prior`.
1588 let prior = metadata.get("_prior").expect("_prior wrap key present");
1589 assert_eq!(
1590 prior,
1591 &serde_json::json!(["unexpected", "array", "payload"]),
1592 "prior array should round-trip under _prior"
1593 );
1594 // New key alongside.
1595 assert_eq!(
1596 metadata
1597 .get("spillover_path")
1598 .and_then(serde_json::Value::as_str),
1599 Some(path.display().to_string().as_str())
1600 );
1601 });
1602 }
1603
1604 // ── Honest-truncation regressions (v0.9.4) ─────────────────────────────
1605
1606 #[test]
1607 fn truncated_preview_returns_content_unchanged_when_nothing_omitted() {
1608 let original = "line one\nline two\nline three\n";
1609 let preview = truncated_preview(original, "", original, "/tmp/artifact.txt", None);
1610 assert_eq!(preview, original);
1611 assert!(
1612 !preview.contains("of output omitted"),
1613 "must never claim a truncation that did not happen"
1614 );
1615 }
1616
1617 #[test]
1618 fn head_tail_windows_never_overlap() {
1619 // Content smaller than head + tail budgets: the tail window shrinks
1620 // so it starts exactly where the head ends — no byte appears twice.
1621 let content = "x".repeat(10_000);
1622 let (head, tail) = head_tail_windows(&content, 8 * 1024, 4 * 1024);
1623 assert_eq!(head.len(), 8 * 1024);
1624 assert_eq!(tail.len(), 10_000 - 8 * 1024);
1625 assert!(head.len() + tail.len() <= content.len());
1626
1627 // Content larger than both budgets: full windows, exact omission.
1628 let big = "y".repeat(100_000);
1629 let (head, tail) = head_tail_windows(&big, 32 * 1024, 8 * 1024);
1630 assert_eq!(head.len(), 32 * 1024);
1631 assert_eq!(tail.len(), 8 * 1024);
1632
1633 // UTF-8 codepoints are never split at either window edge.
1634 let emoji = "🐳".repeat(5_000); // 20_000 bytes, 4 per codepoint
1635 let (head, tail) = head_tail_windows(&emoji, 8 * 1024 + 1, 4 * 1024 + 2);
1636 assert!(emoji.is_char_boundary(head.len()));
1637 assert!(emoji.is_char_boundary(emoji.len() - tail.len()));
1638 assert!(head.len() + tail.len() <= emoji.len());
1639 }
1640
1641 #[test]
1642 fn adaptive_evidence_passes_through_when_preview_budget_covers_output() {
1643 let _g = setup();
1644 let tmp = tempdir().unwrap();
1645 with_test_home(tmp.path(), || {
1646 // 30_000 bytes → 10_000 estimated tokens → Hybrid band under the
1647 // 32_768-token default, but the 32 KiB + 8 KiB preview budget
1648 // covers the whole output, so nothing is actually omitted.
1649 let raw = "mid\n".repeat(7_500);
1650 assert_eq!(raw.len(), 30_000);
1651 let mut result = ToolResult::success(raw.clone());
1652 let path = apply_spillover_with_artifact(
1653 &mut result,
1654 "call-covered",
1655 "exec_shell",
1656 "session-covered",
1657 );
1658 assert!(path.is_none(), "no artifact when nothing is omitted");
1659 assert_eq!(result.content, raw);
1660 assert!(!result.content.contains("of output omitted"));
1661 assert!(
1662 !tmp.path()
1663 .join(".codewhale/sessions/session-covered/artifacts/art_call-covered.txt")
1664 .exists()
1665 );
1666 });
1667 }
1668
1669 #[test]
1670 fn adaptive_evidence_footer_names_artifact_path_and_recovery() {
1671 let _g = setup();
1672 let tmp = tempdir().unwrap();
1673 with_test_home(tmp.path(), || {
1674 // 120_000 bytes → 40_000 estimated tokens → handle-only band.
1675 let raw = "entry\n".repeat(20_000);
1676 assert_eq!(raw.len(), 120_000);
1677 let mut result = ToolResult::success(raw);
1678 let path = apply_spillover_with_artifact(
1679 &mut result,
1680 "call-honest",
1681 "exec_shell",
1682 "session-honest",
1683 )
1684 .expect("should spill");
1685
1686 // Footer: omitted size + line count, artifact path, recovery line.
1687 assert!(result.content.contains("of output omitted ("));
1688 assert!(result.content.contains(" lines)"));
1689 assert!(result.content.contains("full output at"));
1690 assert!(result.content.contains(&path.display().to_string()));
1691 assert!(result.content.contains(SPILLOVER_RECOVERY_HINT));
1692 assert!(!result.content.contains(SPILLOVER_PREVIEW_HINT));
1693
1694 // Head and tail do not overlap: 16 KiB + 4 KiB handle-only
1695 // windows over a 120_000-byte output.
1696 let metadata = result.metadata.expect("metadata stamped");
1697 assert_eq!(metadata["retained_head_bytes"], 16 * 1024);
1698 assert_eq!(metadata["retained_tail_bytes"], 4 * 1024);
1699 });
1700 }
1701 }
1702
1702 lines RUST