返回 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 #[cfg_attr(not(test), expect(dead_code))]
464 pub fn apply_spillover(result: &mut ToolResult, tool_id: &str) -> Option<PathBuf> {
465 apply_spillover_inner(result, tool_id, None, false)
466 }
467
468 /// Apply spillover and publish session-scoped exact evidence.
469 ///
470 /// The default (classic) path writes the full bytes under the origin session
471 /// and replaces oversized content with a bounded head/tail preview whose
472 /// footer names the artifact path and how to read the omitted range back.
473 /// The adaptive evidence lane is reachable only through the explicit
474 /// `CODEWHALE_ADAPTIVE_OUTPUT_ROUTING` opt-in.
475 pub fn apply_spillover_with_artifact(
476 result: &mut ToolResult,
477 tool_id: &str,
478 tool_name: &str,
479 session_id: &str,
480 ) -> Option<PathBuf> {
481 apply_spillover_inner(
482 result,
483 tool_id,
484 Some(ArtifactSpilloverContext {
485 tool_name,
486 session_id,
487 }),
488 false,
489 )
490 }
491
492 /// [`apply_spillover_with_artifact`] for callers whose error payloads are
493 /// routinely as large as their successes — sub-agent tool output is often a
494 /// full build log, so the root loop's pass-errors-through rationale does not
495 /// hold there.
496 pub(crate) fn apply_spillover_with_artifact_including_errors(
497 result: &mut ToolResult,
498 tool_id: &str,
499 tool_name: &str,
500 session_id: &str,
501 ) -> Option<PathBuf> {
502 apply_spillover_inner(
503 result,
504 tool_id,
505 Some(ArtifactSpilloverContext {
506 tool_name,
507 session_id,
508 }),
509 true,
510 )
511 }
512
513 #[derive(Clone, Copy)]
514 struct ArtifactSpilloverContext<'a> {
515 tool_name: &'a str,
516 session_id: &'a str,
517 }
518
519 fn apply_spillover_inner(
520 result: &mut ToolResult,
521 tool_id: &str,
522 artifact_context: Option<ArtifactSpilloverContext<'_>>,
523 bound_errors: bool,
524 ) -> Option<PathBuf> {
525 if crate::tools::large_output_router::adaptive_output_routing_enabled()
526 && let Some(context) = artifact_context
527 {
528 return apply_adaptive_evidence_inner(result, tool_id, context);
529 }
530 if !result.success && !bound_errors {
531 return None;
532 }
533 if result.content.len() <= SPILLOVER_THRESHOLD_BYTES {
534 return None;
535 }
536 let original_content = result.content.clone();
537 let outcome = match maybe_spillover(
538 tool_id,
539 &original_content,
540 SPILLOVER_THRESHOLD_BYTES,
541 SPILLOVER_HEAD_BYTES,
542 ) {
543 Ok(Some(pair)) => pair,
544 Ok(None) => return None,
545 Err(err) => {
546 tracing::warn!(
547 target: "spillover",
548 ?err,
549 tool_id,
550 "spillover write failed; passing original content through"
551 );
552 return None;
553 }
554 };
555 let (_head, path) = outcome;
556 let (head, tail) = head_tail_windows(
557 &original_content,
558 SPILLOVER_HEAD_BYTES,
559 SPILLOVER_TAIL_BYTES,
560 );
561 let digest = crate::hashing::sha256_hex(original_content.as_bytes());
562 let path_str = path.display().to_string();
563
564 // Keep publishing the legacy ownership proof even though the model-facing
565 // footer no longer mentions retrieval: the tool-details pager authorizes
566 // legacy spillover reads through this sidecar.
567 if let Some(context) = artifact_context
568 && let Err(err) = publish_legacy_spillover_ownership(
569 &path,
570 context.session_id,
571 original_content.as_bytes(),
572 )
573 {
574 tracing::warn!(
575 target: "spillover",
576 ?err,
577 tool_id,
578 "legacy spillover ownership publication failed"
579 );
580 }
581
582 let mut artifact_path = None;
583 if let Some(context) = artifact_context {
584 let artifact_id = crate::artifacts::artifact_id_for_tool_call(tool_id);
585 match crate::artifacts::write_session_artifact(
586 context.session_id,
587 &artifact_id,
588 &original_content,
589 ) {
590 Ok((absolute_path, relative_path)) => {
591 let record = crate::artifacts::record_tool_output_artifact(
592 context.session_id,
593 tool_id,
594 context.tool_name,
595 relative_path.clone(),
596 &original_content,
597 );
598 result.content = truncated_preview(
599 head,
600 tail,
601 &original_content,
602 &crate::artifacts::format_artifact_relative_path(&absolute_path),
603 Some(artifact_id.as_str()),
604 );
605 artifact_path = Some((absolute_path, relative_path, record));
606 }
607 Err(err) => {
608 tracing::warn!(
609 target: "spillover",
610 ?err,
611 tool_id,
612 "session artifact write failed; falling back to legacy spillover footer"
613 );
614 }
615 }
616 }
617
618 if artifact_path.is_none() {
619 // Legacy fallback: no session artifact was written, so there is no
620 // `art_<id>` ref to hand over — only the on-disk path.
621 result.content = truncated_preview(head, tail, &original_content, &path_str, None);
622 }
623
624 let metadata = result.metadata.get_or_insert_with(|| serde_json::json!({}));
625 if let Some(obj) = metadata.as_object_mut() {
626 if let Some((absolute_path, relative_path, record)) = artifact_path.as_ref() {
627 obj.insert(
628 "spillover_path".into(),
629 serde_json::Value::String(absolute_path.display().to_string()),
630 );
631 obj.insert(
632 "legacy_spillover_path".into(),
633 serde_json::Value::String(path_str),
634 );
635 obj.insert(
636 "artifact_id".into(),
637 serde_json::Value::String(record.id.clone()),
638 );
639 obj.insert(
640 "artifact_session_id".into(),
641 serde_json::Value::String(record.session_id.clone()),
642 );
643 obj.insert(
644 "artifact_relative_path".into(),
645 serde_json::Value::String(crate::artifacts::format_artifact_relative_path(
646 relative_path,
647 )),
648 );
649 obj.insert(
650 "artifact_path".into(),
651 serde_json::Value::String(absolute_path.display().to_string()),
652 );
653 obj.insert(
654 "artifact_byte_size".into(),
655 serde_json::Value::Number(serde_json::Number::from(record.byte_size)),
656 );
657 obj.insert(
658 "artifact_preview".into(),
659 serde_json::Value::String(record.preview.clone()),
660 );
661 } else {
662 obj.insert("spillover_path".into(), serde_json::Value::String(path_str));
663 }
664 } else {
665 // Pre-existing metadata that wasn't a JSON object (rare,
666 // possibly an array). Replace with an object so we can
667 // attach our key without losing prior data — wrap it under
668 // a `_prior` field so callers that introspect can recover.
669 let prior = std::mem::replace(metadata, serde_json::json!({}));
670 if let Some(obj) = metadata.as_object_mut() {
671 obj.insert("_prior".into(), prior);
672 if let Some((absolute_path, relative_path, record)) = artifact_path.as_ref() {
673 obj.insert(
674 "spillover_path".into(),
675 serde_json::Value::String(absolute_path.display().to_string()),
676 );
677 obj.insert(
678 "legacy_spillover_path".into(),
679 serde_json::Value::String(path.display().to_string()),
680 );
681 obj.insert(
682 "artifact_id".into(),
683 serde_json::Value::String(record.id.clone()),
684 );
685 obj.insert(
686 "artifact_session_id".into(),
687 serde_json::Value::String(record.session_id.clone()),
688 );
689 obj.insert(
690 "artifact_relative_path".into(),
691 serde_json::Value::String(crate::artifacts::format_artifact_relative_path(
692 relative_path,
693 )),
694 );
695 obj.insert(
696 "artifact_path".into(),
697 serde_json::Value::String(absolute_path.display().to_string()),
698 );
699 obj.insert(
700 "artifact_byte_size".into(),
701 serde_json::Value::Number(serde_json::Number::from(record.byte_size)),
702 );
703 obj.insert(
704 "artifact_preview".into(),
705 serde_json::Value::String(record.preview.clone()),
706 );
707 } else {
708 obj.insert(
709 "spillover_path".into(),
710 serde_json::Value::String(path.display().to_string()),
711 );
712 }
713 }
714 }
715 if let Some(obj) = result
716 .metadata
717 .as_mut()
718 .and_then(serde_json::Value::as_object_mut)
719 {
720 obj.insert("truncated".into(), serde_json::Value::Bool(true));
721 obj.insert(
722 "content_digest".into(),
723 serde_json::Value::String(format!("sha256:{digest}")),
724 );
725 obj.insert(
726 "original_byte_count".into(),
727 serde_json::Value::Number(serde_json::Number::from(original_content.len() as u64)),
728 );
729 obj.insert(
730 "retained_head_bytes".into(),
731 serde_json::Value::Number(serde_json::Number::from(head.len() as u64)),
732 );
733 obj.insert(
734 "retained_tail_bytes".into(),
735 serde_json::Value::Number(serde_json::Number::from(tail.len() as u64)),
736 );
737 }
738 artifact_path
739 .map(|(absolute_path, _, _)| absolute_path)
740 .or(Some(path))
741 }
742
743 fn apply_adaptive_evidence_inner(
744 result: &mut ToolResult,
745 tool_id: &str,
746 context: ArtifactSpilloverContext<'_>,
747 ) -> Option<PathBuf> {
748 use crate::tools::large_output_router::{
749 DEFAULT_LARGE_OUTPUT_THRESHOLD_TOKENS, EVIDENCE_RETENTION_SECS, EvidenceArtifact,
750 EvidenceRetentionState, EvidenceRouting, estimate_tokens, publish_evidence_metadata,
751 unix_millis_now,
752 };
753
754 let estimated_tokens = estimate_tokens(&result.content);
755 let threshold = result
756 .metadata
757 .as_ref()
758 .and_then(|metadata| metadata.get("evidence_threshold_tokens"))
759 .and_then(serde_json::Value::as_u64)
760 .and_then(|value| usize::try_from(value).ok())
761 .unwrap_or(DEFAULT_LARGE_OUTPUT_THRESHOLD_TOKENS);
762 let routing = result
763 .metadata
764 .as_ref()
765 .and_then(|metadata| metadata.get("evidence_routing"))
766 .cloned()
767 .and_then(|value| serde_json::from_value::<EvidenceRouting>(value).ok())
768 .unwrap_or_else(|| EvidenceRouting::from_token_estimate(estimated_tokens, threshold));
769 if routing == EvidenceRouting::Inline {
770 return None;
771 }
772
773 let original = result.content.clone();
774 let (head_bytes, tail_bytes) = if routing == EvidenceRouting::Hybrid {
775 (HYBRID_HEAD_BYTES, HYBRID_TAIL_BYTES)
776 } else {
777 (HANDLE_ONLY_HEAD_BYTES, HANDLE_ONLY_TAIL_BYTES)
778 };
779 let (head, tail) = head_tail_windows(&original, head_bytes, tail_bytes);
780 let omitted = original.len().saturating_sub(head.len() + tail.len());
781 if omitted == 0 {
782 // The whole output fits inside the preview budget: there is nothing
783 // to recover, so publishing an artifact and claiming a truncation
784 // would both be dishonest. Pass the content through unchanged.
785 return None;
786 }
787 let head_len = head.len();
788 let tail_len = tail.len();
789
790 let artifact_id = crate::artifacts::artifact_id_for_tool_call(tool_id);
791 let relative_path = crate::artifacts::session_artifact_relative_path(&artifact_id);
792 let digest = crate::hashing::sha256_hex(original.as_bytes());
793 let now_ms = unix_millis_now();
794 let proposed_artifact = EvidenceArtifact {
795 handle: artifact_id.clone(),
796 digest: digest.clone(),
797 size_bytes: original.len().try_into().unwrap_or(u64::MAX),
798 content_type: if serde_json::from_str::<serde_json::Value>(&original).is_ok() {
799 "application/json".to_string()
800 } else {
801 "text/plain".to_string()
802 },
803 tool_name: context.tool_name.to_string(),
804 call_id: tool_id.to_string(),
805 origin_session: context.session_id.to_string(),
806 generation: 1,
807 redacted: false,
808 encoding: "utf-8".to_string(),
809 retention_state: EvidenceRetentionState::Live,
810 created_at_unix_ms: now_ms,
811 retain_until_unix_ms: now_ms.saturating_add(EVIDENCE_RETENTION_SECS * 1_000),
812 storage_path: relative_path.clone(),
813 };
814 let artifact = match crate::tools::large_output_router::read_evidence_metadata(
815 context.session_id,
816 &artifact_id,
817 ) {
818 Ok(existing)
819 if existing.digest == proposed_artifact.digest
820 && existing.size_bytes == proposed_artifact.size_bytes
821 && existing.call_id == proposed_artifact.call_id
822 && existing.origin_session == proposed_artifact.origin_session =>
823 {
824 existing
825 }
826 Ok(_) => {
827 tracing::warn!(target: "evidence", tool_id, "adaptive evidence replay conflicts with immutable metadata");
828 return None;
829 }
830 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
831 if let Err(err) = publish_evidence_metadata(context.session_id, &proposed_artifact) {
832 tracing::warn!(target: "evidence", ?err, tool_id, "adaptive evidence metadata publication failed");
833 return None;
834 }
835 proposed_artifact
836 }
837 Err(err) => {
838 tracing::warn!(target: "evidence", ?err, tool_id, "adaptive evidence metadata validation failed");
839 return None;
840 }
841 };
842
843 // Seal the ownership/integrity record before publishing predictable
844 // `art_<call>.txt` bytes. If metadata publication fails, no payload exists
845 // for a guessed handle to retrieve without the generation, redaction,
846 // retention, size, and digest checks above. A metadata-only interruption
847 // is safe: the handle is never advertised and a retry can idempotently
848 // publish the matching bytes.
849 let (absolute_path, relative_path) = match crate::artifacts::write_session_artifact_immutable(
850 context.session_id,
851 &artifact_id,
852 original.as_bytes(),
853 ) {
854 Ok(paths) => paths,
855 Err(err) => {
856 tracing::warn!(target: "evidence", ?err, tool_id, "adaptive evidence content publication failed");
857 return None;
858 }
859 };
860
861 let record = crate::artifacts::record_tool_output_artifact(
862 context.session_id,
863 tool_id,
864 context.tool_name,
865 relative_path.clone(),
866 &original,
867 );
868 result.content = truncated_preview(
869 head,
870 tail,
871 &original,
872 &crate::artifacts::format_artifact_relative_path(&absolute_path),
873 Some(artifact_id.as_str()),
874 );
875 let metadata = result.metadata.get_or_insert_with(|| serde_json::json!({}));
876 if let Some(object) = metadata.as_object_mut() {
877 object.insert(
878 "spillover_path".into(),
879 absolute_path.display().to_string().into(),
880 );
881 object.insert("artifact_id".into(), artifact_id.into());
882 object.insert("artifact_session_id".into(), context.session_id.into());
883 object.insert(
884 "artifact_relative_path".into(),
885 crate::artifacts::format_artifact_relative_path(&relative_path).into(),
886 );
887 object.insert("artifact_byte_size".into(), artifact.size_bytes.into());
888 object.insert("artifact_digest".into(), digest.into());
889 object.insert("artifact_generation".into(), artifact.generation.into());
890 object.insert("artifact_encoding".into(), artifact.encoding.into());
891 object.insert("artifact_retention_state".into(), "live".into());
892 object.insert("evidence_available".into(), true.into());
893 object.insert("truncated".into(), true.into());
894 object.insert("original_byte_count".into(), artifact.size_bytes.into());
895 object.insert("retained_head_bytes".into(), head_len.into());
896 object.insert("retained_tail_bytes".into(), tail_len.into());
897 object.insert(
898 "artifact_preview".into(),
899 original.chars().take(200).collect::<String>().into(),
900 );
901 object.insert(
902 "artifact_record".into(),
903 serde_json::to_value(record).unwrap_or(serde_json::Value::Null),
904 );
905 }
906 Some(absolute_path)
907 }
908
909 /// Sanitise a tool call id for use as a filename. Keeps ASCII
910 /// alphanumerics, `-`, and `_`; rejects `.` to keep `..` traversal
911 /// out, rejects empty results. Returns `None` if the input contains
912 /// no acceptable characters.
913 fn sanitise_id(id: &str) -> Option<String> {
914 let cleaned: String = id
915 .chars()
916 .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
917 .collect();
918 if cleaned.is_empty() {
919 None
920 } else {
921 Some(cleaned)
922 }
923 }
924
925 /// Override the storage roots for tests so they don't pollute the
926 /// user's real `~/.codewhale/` directory. This uses explicit test hooks instead
927 /// of `$HOME` because Windows home-dir resolution can ignore environment
928 /// overrides and return the runner profile directory.
929 #[cfg(test)]
930 fn with_test_home<F, R>(home: &Path, f: F) -> R
931 where
932 F: FnOnce() -> R,
933 {
934 let _artifact_guard = crate::artifacts::TEST_ARTIFACT_SESSIONS_GUARD
935 .lock()
936 .unwrap_or_else(|err| err.into_inner());
937
938 struct StorageRootOverride {
939 prior_spillover: Option<PathBuf>,
940 prior_artifacts: Option<PathBuf>,
941 }
942
943 impl Drop for StorageRootOverride {
944 fn drop(&mut self) {
945 set_test_spillover_root(self.prior_spillover.take());
946 crate::artifacts::set_test_artifact_sessions_root(self.prior_artifacts.take());
947 }
948 }
949
950 // Tests in this module serialize spillover through `TEST_GUARD`; the
951 // artifact guard above protects the session-artifact root shared with
952 // artifacts.rs tests.
953 let prior_spillover =
954 set_test_spillover_root(Some(home.join(".codewhale").join(SPILLOVER_DIR_NAME)));
955 let prior_artifacts = crate::artifacts::set_test_artifact_sessions_root(Some(
956 home.join(".codewhale").join("sessions"),
957 ));
958 let _restore = StorageRootOverride {
959 prior_spillover,
960 prior_artifacts,
961 };
962 f()
963 }
964
965 #[cfg(test)]
966 mod tests {
967 use super::*;
968 use tempfile::tempdir;
969
970 /// Tests in this module serialize through this guard because they mutate
971 /// process-global test storage roots. Without it, cargo's parallel runner
972 /// would observe interleaved overrides.
973 fn setup() -> std::sync::MutexGuard<'static, ()> {
974 super::TEST_SPILLOVER_GUARD
975 .lock()
976 .unwrap_or_else(|e| e.into_inner())
977 }
978
979 /// Run the adaptive evidence lane directly. These cases exercise it
980 /// without the `CODEWHALE_ADAPTIVE_OUTPUT_ROUTING` process opt-in so
981 /// parallel tests keep a deterministic routing decision.
982 fn adaptive_spillover(
983 result: &mut ToolResult,
984 tool_id: &str,
985 tool_name: &str,
986 session_id: &str,
987 ) -> Option<PathBuf> {
988 apply_adaptive_evidence_inner(
989 result,
990 tool_id,
991 ArtifactSpilloverContext {
992 tool_name,
993 session_id,
994 },
995 )
996 }
997
998 /// The old hint named `read_file`, which is not registered for the model
999 /// at all, and otherwise pointed at routes that only reach the artifact
1000 /// under trust mode (see [`spillover_recovery_instruction`]), while never
1001 /// naming `retrieve_tool_result` — model-visible, unconditional, and built
1002 /// for exactly this.
1003 #[test]
1004 fn truncation_footer_names_a_recovery_route_that_works() {
1005 let footer = spillover_preview_footer(
1006 4096,
1007 120,
1008 "/tmp/artifacts/art_call-1.txt",
1009 Some("art_call-1"),
1010 );
1011
1012 assert!(footer.contains("retrieve_tool_result"), "{footer}");
1013 assert!(footer.contains("ref=\"art_call-1\""), "{footer}");
1014 assert!(!footer.contains("read_file"), "{footer}");
1015 assert!(!footer.contains("sed"), "{footer}");
1016 }
1017
1018 /// The legacy global copy has no guaranteed authorization sidecar, so the
1019 /// footer must not invent a fourth dead route — but it still must not name
1020 /// the three it used to.
1021 #[test]
1022 fn truncation_footer_without_an_artifact_promises_nothing_it_cannot_deliver() {
1023 let footer = spillover_preview_footer(4096, 120, "/tmp/tool_outputs/call-1.txt", None);
1024
1025 assert!(
1026 footer.contains("no tool call reaches this copy"),
1027 "{footer}"
1028 );
1029 assert!(!footer.contains("retrieve_tool_result"), "{footer}");
1030 assert!(!footer.contains("read_file"), "{footer}");
1031 assert!(!footer.contains("sed"), "{footer}");
1032 }
1033
1034 /// The TUI keys its "this preview was truncated" detection off the shared
1035 /// constant, so it has to stay a literal substring of every variant.
1036 #[test]
1037 fn every_footer_variant_carries_the_ui_detection_marker() {
1038 for reference in [Some("art_call-1"), None] {
1039 let footer = spillover_preview_footer(4096, 120, "/tmp/x.txt", reference);
1040 assert!(footer.contains(SPILLOVER_RECOVERY_HINT), "{footer}");
1041 }
1042 }
1043
1044 #[test]
1045 fn with_test_home_overrides_storage_roots_without_home_resolution() {
1046 let _g = setup();
1047 let tmp = tempdir().unwrap();
1048
1049 with_test_home(tmp.path(), || {
1050 assert_eq!(
1051 spillover_root().as_deref(),
1052 Some(tmp.path().join(".codewhale").join("tool_outputs").as_path())
1053 );
1054 assert_eq!(
1055 crate::artifacts::session_artifact_absolute_path(
1056 "session-123",
1057 &PathBuf::from("artifacts").join("art_call-big.txt")
1058 )
1059 .as_deref(),
1060 Some(
1061 tmp.path()
1062 .join(".codewhale")
1063 .join("sessions")
1064 .join("session-123")
1065 .join("artifacts")
1066 .join("art_call-big.txt")
1067 .as_path()
1068 )
1069 );
1070 });
1071 }
1072
1073 #[test]
1074 fn sanitise_id_keeps_safe_chars_and_drops_dangerous() {
1075 assert_eq!(super::sanitise_id("abc-123_x"), Some("abc-123_x".into()));
1076 // `.` is dropped to keep `..` out of the path.
1077 assert_eq!(super::sanitise_id("../etc"), Some("etc".into()));
1078 assert_eq!(super::sanitise_id("/etc/passwd"), Some("etcpasswd".into()));
1079 // Empty-after-sanitise → None.
1080 assert!(super::sanitise_id("...").is_none());
1081 assert!(super::sanitise_id("").is_none());
1082 }
1083
1084 #[test]
1085 fn write_spillover_creates_directory_and_writes_file() {
1086 let _g = setup();
1087 let tmp = tempdir().unwrap();
1088 with_test_home(tmp.path(), || {
1089 let path = write_spillover("call-abc", "hello world").expect("write");
1090 assert!(path.exists(), "{path:?} missing");
1091 let body = fs::read_to_string(&path).unwrap();
1092 assert_eq!(body, "hello world");
1093 // Directory landed under `<HOME>/.codewhale/tool_outputs/`.
1094 // Compare components instead of a substring on `to_string_lossy`
1095 // — Windows uses `\` as the separator so a `/` substring match
1096 // would falsely fail there.
1097 let components: Vec<&str> = path
1098 .components()
1099 .filter_map(|c| c.as_os_str().to_str())
1100 .collect();
1101 assert!(
1102 components.contains(&".codewhale") && components.contains(&"tool_outputs"),
1103 "spillover path missing expected `.codewhale/tool_outputs/...` segments: {path:?}"
1104 );
1105 });
1106 }
1107
1108 #[test]
1109 fn write_spillover_rejects_empty_id() {
1110 let _g = setup();
1111 let tmp = tempdir().unwrap();
1112 with_test_home(tmp.path(), || {
1113 let err = write_spillover("...", "x").unwrap_err();
1114 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
1115 });
1116 }
1117
1118 #[test]
1119 fn maybe_spillover_returns_none_below_threshold() {
1120 let _g = setup();
1121 let tmp = tempdir().unwrap();
1122 with_test_home(tmp.path(), || {
1123 let out = maybe_spillover("call-1", "tiny content", 100 * 1024, 4 * 1024).expect("ok");
1124 assert!(out.is_none());
1125 });
1126 }
1127
1128 #[test]
1129 fn maybe_spillover_writes_and_returns_head_above_threshold() {
1130 let _g = setup();
1131 let tmp = tempdir().unwrap();
1132 with_test_home(tmp.path(), || {
1133 // Content larger than the threshold.
1134 let big = "A".repeat(2_000);
1135 let (head, path) = maybe_spillover("call-2", &big, 1_000, 256)
1136 .expect("ok")
1137 .expect("should have spilled");
1138 // Head is bounded.
1139 assert_eq!(head.len(), 256);
1140 // Full content on disk.
1141 let body = fs::read_to_string(&path).unwrap();
1142 assert_eq!(body.len(), 2_000);
1143 });
1144 }
1145
1146 #[test]
1147 fn maybe_spillover_does_not_split_inside_a_codepoint() {
1148 let _g = setup();
1149 let tmp = tempdir().unwrap();
1150 with_test_home(tmp.path(), || {
1151 // 4 byte chars; ask for 3 bytes of head → walks back to
1152 // the previous char boundary (0).
1153 let s = "🐳🐳🐳🐳"; // 4 × 4-byte codepoints
1154 assert_eq!(s.len(), 16);
1155 let (head, _) = maybe_spillover("call-3", s, 1, 3)
1156 .expect("ok")
1157 .expect("spilled");
1158 // 3 isn't a char boundary in this string; walk back → 0.
1159 assert_eq!(head, "");
1160 // Asking for 4 bytes lands on the first char boundary.
1161 let (head, _) = maybe_spillover("call-3b", s, 1, 4)
1162 .expect("ok")
1163 .expect("spilled");
1164 assert_eq!(head, "🐳");
1165 });
1166 }
1167
1168 #[test]
1169 fn prune_older_than_handles_missing_root() {
1170 let _g = setup();
1171 let tmp = tempdir().unwrap();
1172 with_test_home(tmp.path(), || {
1173 // Nothing has ever written; root doesn't exist; that's fine.
1174 let count = prune_older_than(SPILLOVER_MAX_AGE).expect("ok");
1175 assert_eq!(count, 0);
1176 });
1177 }
1178
1179 // The mtime backdate uses utimensat (Unix-only). On Windows the
1180 // filetime_set_modified helper is a no-op, so the prune wouldn't see
1181 // any stale files. Gate the whole test on `cfg(unix)` instead of
1182 // testing a no-op path that can't fail meaningfully.
1183 #[test]
1184 #[cfg(unix)]
1185 fn prune_older_than_keeps_fresh_files_drops_stale_ones() {
1186 let _g = setup();
1187 let tmp = tempdir().unwrap();
1188 with_test_home(tmp.path(), || {
1189 let fresh = write_spillover("fresh", "x").unwrap();
1190 let stale = write_spillover("stale", "y").unwrap();
1191
1192 // Backdate `stale` to 30 days ago.
1193 let thirty_days = SystemTime::now() - Duration::from_secs(30 * 24 * 60 * 60);
1194 filetime_set_modified(&stale, thirty_days);
1195
1196 let pruned = prune_older_than(SPILLOVER_MAX_AGE).unwrap();
1197 assert_eq!(pruned, 1);
1198 assert!(fresh.exists());
1199 assert!(!stale.exists());
1200 });
1201 }
1202
1203 /// Set the mtime on a file. The workspace doesn't pull the
1204 /// `filetime` crate, so we reach for `utimensat` directly on
1205 /// Unix. Windows is a no-op — the prune semantics are the same
1206 /// and the per-cycle stress test lives on the Unix path.
1207 #[cfg(unix)]
1208 fn filetime_set_modified(path: &Path, when: SystemTime) {
1209 let secs = when
1210 .duration_since(SystemTime::UNIX_EPOCH)
1211 .unwrap_or_default()
1212 .as_secs() as libc::time_t;
1213 let times = [
1214 libc::timespec {
1215 tv_sec: secs,
1216 tv_nsec: 0,
1217 },
1218 libc::timespec {
1219 tv_sec: secs,
1220 tv_nsec: 0,
1221 },
1222 ];
1223 let path_c = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()).unwrap();
1224 // SAFETY: path_c is a valid CString; times is a 2-element array
1225 // matching utimensat's signature.
1226 let rc = unsafe { libc::utimensat(libc::AT_FDCWD, path_c.as_ptr(), times.as_ptr(), 0) };
1227 assert_eq!(
1228 rc,
1229 0,
1230 "utimensat failed: {}",
1231 std::io::Error::last_os_error()
1232 );
1233 }
1234
1235 // Windows stub removed in v0.8.8 — the only caller of
1236 // `filetime_set_modified` is `prune_older_than_keeps_fresh_files_drops_stale_ones`,
1237 // which is now `#[cfg(unix)]` because mtime backdating requires
1238 // `utimensat` and a Windows no-op stub can't make the assertion pass
1239 // anyway. Keeping the stub triggered `-D dead-code` on Windows builds
1240 // (the prune test was the only caller) and broke `Test (windows-latest)`.
1241
1242 #[test]
1243 fn apply_spillover_is_noop_below_threshold() {
1244 let _g = setup();
1245 let tmp = tempdir().unwrap();
1246 with_test_home(tmp.path(), || {
1247 let mut result = ToolResult::success("small payload");
1248 let path = apply_spillover(&mut result, "call-small");
1249 assert!(path.is_none());
1250 assert_eq!(result.content, "small payload");
1251 assert!(result.metadata.is_none());
1252 });
1253 }
1254
1255 #[test]
1256 fn apply_spillover_is_noop_for_error_results() {
1257 let _g = setup();
1258 let tmp = tempdir().unwrap();
1259 with_test_home(tmp.path(), || {
1260 // Even very large error messages are passed through —
1261 // truncating an error would hide it from the model.
1262 let big_err = "boom\n".repeat(50_000);
1263 let mut result = ToolResult::error(big_err.clone());
1264 let path = apply_spillover(&mut result, "call-err");
1265 assert!(path.is_none());
1266 assert_eq!(result.content, big_err);
1267 });
1268 }
1269
1270 #[test]
1271 fn apply_spillover_truncates_and_stamps_metadata_above_threshold() {
1272 let _g = setup();
1273 let tmp = tempdir().unwrap();
1274 with_test_home(tmp.path(), || {
1275 // 200 KiB body — well above the 100 KiB threshold.
1276 let big = "X".repeat(200 * 1024);
1277 let mut result = ToolResult::success(big.clone());
1278 let path = apply_spillover(&mut result, "call-big").expect("should spill");
1279
1280 // Inline content shrunk to head + honest preview footer.
1281 assert!(result.content.len() < big.len());
1282 assert!(
1283 !result.content.contains(SPILLOVER_PREVIEW_HINT),
1284 "the tool-details phrase is a UI affordance, not model-facing"
1285 );
1286 assert!(
1287 result.content.contains("of output omitted"),
1288 "footer missing: {}",
1289 &result.content[result.content.len().saturating_sub(200)..]
1290 );
1291 // The footer tells the model where the full output lives and how
1292 // to read the omitted range back.
1293 assert!(result.content.contains("full output at"));
1294 assert!(result.content.contains(&path.display().to_string()));
1295 assert!(result.content.contains(SPILLOVER_RECOVERY_HINT));
1296 assert!(
1297 !result.content.contains("retrieve_tool_result"),
1298 "legacy spillover ownership can fail to publish; promising \
1299 retrieval here would be another dead route"
1300 );
1301 assert!(!result.content.contains("read_file"));
1302 assert!(!result.content.contains("sed"));
1303
1304 // Full bytes are on disk at the returned path.
1305 assert!(path.exists(), "spillover file missing: {path:?}");
1306 let body = fs::read_to_string(&path).unwrap();
1307 assert_eq!(body.len(), 200 * 1024);
1308
1309 // metadata.spillover_path stamped for the UI to find.
1310 let metadata = result.metadata.expect("metadata stamped");
1311 let stamped = metadata
1312 .get("spillover_path")
1313 .and_then(serde_json::Value::as_str)
1314 .expect("spillover_path key present");
1315 assert_eq!(stamped, path.display().to_string());
1316 assert_eq!(metadata["truncated"], true);
1317 assert_eq!(metadata["original_byte_count"], 200 * 1024);
1318 assert_eq!(metadata["retained_head_bytes"], SPILLOVER_HEAD_BYTES);
1319 assert_eq!(metadata["retained_tail_bytes"], SPILLOVER_TAIL_BYTES);
1320 assert!(
1321 metadata["content_digest"]
1322 .as_str()
1323 .is_some_and(|digest| digest.starts_with("sha256:"))
1324 );
1325 });
1326 }
1327
1328 #[test]
1329 fn apply_spillover_with_artifact_writes_session_file_and_plain_preview() {
1330 let _g = setup();
1331 let tmp = tempdir().unwrap();
1332 with_test_home(tmp.path(), || {
1333 let big = "checking crate ... error[E0425]: cannot find value\n".repeat(4_000);
1334 let mut result = ToolResult::success(big.clone());
1335 let path = adaptive_spillover(&mut result, "call-big", "exec_shell", "session-123")
1336 .expect("should spill");
1337
1338 let session_artifact = tmp
1339 .path()
1340 .join(".codewhale")
1341 .join("sessions")
1342 .join("session-123")
1343 .join("artifacts")
1344 .join("art_call-big.txt");
1345 assert_eq!(path, session_artifact);
1346 assert_eq!(fs::read_to_string(&session_artifact).unwrap(), big);
1347 assert!(
1348 !tmp.path()
1349 .join(".codewhale/tool_outputs/call-big.txt")
1350 .exists(),
1351 "adaptive evidence stores one exact origin-session copy"
1352 );
1353 // The model sees a bounded preview with an honest footer: the
1354 // artifact path plus the retrieval call that actually resolves it.
1355 assert!(!result.content.contains(SPILLOVER_PREVIEW_HINT));
1356 assert!(result.content.contains("\n…\n"));
1357 assert!(result.content.contains("of output omitted"));
1358 assert!(result.content.contains("full output at"));
1359 assert!(result.content.contains(SPILLOVER_RECOVERY_HINT));
1360 assert!(
1361 result.content.contains("art_call-big.txt"),
1362 "footer must name the artifact path so the model can recover the output"
1363 );
1364 assert!(!result.content.contains("Exact evidence retained"));
1365 assert!(
1366 result.content.contains("retrieve_tool_result"),
1367 "a session artifact is retrievable; the footer must say so: {}",
1368 result.content
1369 );
1370 assert!(
1371 result.content.contains("ref=\"art_call-big\""),
1372 "the footer must hand over a ref that resolves: {}",
1373 result.content
1374 );
1375 assert!(
1376 session_artifact
1377 .with_file_name("art_call-big.evidence.json")
1378 .exists()
1379 );
1380
1381 let metadata = result.metadata.expect("metadata stamped");
1382 assert_eq!(
1383 metadata
1384 .get("artifact_id")
1385 .and_then(serde_json::Value::as_str),
1386 Some("art_call-big")
1387 );
1388 assert_eq!(
1389 metadata
1390 .get("artifact_relative_path")
1391 .and_then(serde_json::Value::as_str),
1392 Some("artifacts/art_call-big.txt")
1393 );
1394 assert_eq!(
1395 metadata
1396 .get("artifact_session_id")
1397 .and_then(serde_json::Value::as_str),
1398 Some("session-123")
1399 );
1400 assert_eq!(metadata["original_byte_count"], big.len());
1401 assert!(metadata["retained_head_bytes"].as_u64().unwrap_or(0) <= 16 * 1024);
1402 assert!(metadata["retained_tail_bytes"].as_u64().unwrap_or(0) <= 4 * 1024);
1403 });
1404 }
1405
1406 #[test]
1407 fn adaptive_evidence_keeps_success_and_failure_exact_distinct_and_out_of_context() {
1408 let _g = setup();
1409 let tmp = tempdir().unwrap();
1410 with_test_home(tmp.path(), || {
1411 let sentinel = "DEEP_RAW_SENTINEL";
1412 // Payloads must exceed the 32_768-token (≈96 KiB) handle-only
1413 // threshold so adaptive routing actually spills them.
1414 let success_raw = format!(
1415 "{}{}{}",
1416 "head\n".repeat(30_000),
1417 sentinel,
1418 "tail\n".repeat(30_000)
1419 );
1420 let failure_raw = format!("{}{}", "failure\n".repeat(30_000), "FAILURE_END");
1421 let mut success = ToolResult::success(success_raw.clone());
1422 let mut failure = ToolResult::error(failure_raw.clone());
1423
1424 let success_path =
1425 adaptive_spillover(&mut success, "call-success", "exec_shell", "session-a")
1426 .expect("success evidence");
1427 let failure_path =
1428 adaptive_spillover(&mut failure, "call-failure", "mcp_fixture", "session-a")
1429 .expect("failure evidence");
1430
1431 assert_ne!(success_path, failure_path);
1432 assert_eq!(
1433 std::fs::read(&success_path).unwrap(),
1434 success_raw.as_bytes()
1435 );
1436 assert_eq!(
1437 std::fs::read(&failure_path).unwrap(),
1438 failure_raw.as_bytes()
1439 );
1440 assert!(!success.content.contains(sentinel));
1441 // Handle-only preview: 16 KiB head + 4 KiB tail + footer.
1442 assert!(success.content.len() < 21 * 1024);
1443 let success_meta = success.metadata.as_ref().unwrap();
1444 let failure_meta = failure.metadata.as_ref().unwrap();
1445 assert_ne!(
1446 success_meta["artifact_digest"],
1447 failure_meta["artifact_digest"]
1448 );
1449 assert_eq!(success_meta["artifact_session_id"], "session-a");
1450 assert_eq!(failure_meta["artifact_session_id"], "session-a");
1451
1452 let mut replay = ToolResult::success(success_raw);
1453 let replay_path =
1454 adaptive_spillover(&mut replay, "call-success", "exec_shell", "session-a")
1455 .expect("idempotent replay");
1456 assert_eq!(replay_path, success_path);
1457 });
1458 }
1459
1460 #[test]
1461 fn adaptive_evidence_publication_failure_emits_no_handle_or_details_hint() {
1462 let _g = setup();
1463 let tmp = tempdir().unwrap();
1464 with_test_home(tmp.path(), || {
1465 let session_dir = tmp
1466 .path()
1467 .join(".codewhale")
1468 .join("sessions")
1469 .join("session-blocked");
1470 std::fs::create_dir_all(&session_dir).unwrap();
1471 std::fs::write(session_dir.join("artifacts"), b"block artifact directory").unwrap();
1472
1473 let raw = format!(
1474 "{}{}{}",
1475 "publication failure head\n".repeat(1_500),
1476 "DEEP_FAILURE_SENTINEL",
1477 "publication failure tail\n".repeat(1_500),
1478 );
1479 let mut result = ToolResult::error(raw.clone());
1480 let path = adaptive_spillover(
1481 &mut result,
1482 "call-failed-publish",
1483 "mcp_fixture",
1484 "session-blocked",
1485 );
1486
1487 assert!(path.is_none());
1488 assert_eq!(result.content, raw);
1489 assert!(!result.content.contains(SPILLOVER_PREVIEW_HINT));
1490 assert!(!result.content.contains("retrieve_tool_result"));
1491 assert!(
1492 result
1493 .metadata
1494 .as_ref()
1495 .and_then(|metadata| metadata.get("evidence_available"))
1496 .is_none()
1497 );
1498 assert!(
1499 !session_dir
1500 .join("artifacts/art_call-failed-publish.txt")
1501 .exists()
1502 );
1503 });
1504 }
1505
1506 #[test]
1507 fn adaptive_evidence_metadata_atomic_failure_leaves_payload_unadvertised() {
1508 let _g = setup();
1509 let tmp = tempdir().unwrap();
1510 with_test_home(tmp.path(), || {
1511 let artifact_dir = tmp
1512 .path()
1513 .join(".codewhale")
1514 .join("sessions")
1515 .join("session-metadata-blocked")
1516 .join("artifacts");
1517 std::fs::create_dir_all(artifact_dir.join("art_call-failed-metadata.evidence.json"))
1518 .unwrap();
1519
1520 let raw = format!(
1521 "{}{}{}",
1522 "metadata failure head\n".repeat(1_500),
1523 "DEEP_METADATA_FAILURE_SENTINEL",
1524 "metadata failure tail\n".repeat(1_500),
1525 );
1526 let mut result = ToolResult::success(raw.clone());
1527 let path = adaptive_spillover(
1528 &mut result,
1529 "call-failed-metadata",
1530 "exec_shell",
1531 "session-metadata-blocked",
1532 );
1533
1534 assert!(path.is_none());
1535 assert_eq!(result.content, raw);
1536 assert!(!result.content.contains(SPILLOVER_PREVIEW_HINT));
1537 assert!(!result.content.contains("retrieve_tool_result"));
1538 assert!(
1539 !artifact_dir.join("art_call-failed-metadata.txt").exists(),
1540 "metadata failure must leave no payload behind a guessable handle"
1541 );
1542 });
1543 }
1544
1545 #[test]
1546 fn registry_results_spill_to_artifacts_like_every_other_tool() {
1547 // The Registry bypass is gone: an oversized payload (which today can
1548 // only be a bug, since the tool caps model-visible matches at eight)
1549 // takes the same artifact path as any other tool result.
1550 let _g = setup();
1551 let tmp = tempdir().unwrap();
1552 with_test_home(tmp.path(), || {
1553 let original = "registry-entry\n".repeat(10_000);
1554 assert!(original.len() > SPILLOVER_THRESHOLD_BYTES);
1555 let mut result = ToolResult::success(original);
1556
1557 let path = apply_spillover_with_artifact(
1558 &mut result,
1559 "call-registry",
1560 "registry_sync",
1561 "session-registry",
1562 );
1563
1564 assert!(path.is_some(), "oversized registry payload must spill");
1565 });
1566 }
1567
1568 #[test]
1569 fn apply_spillover_preserves_existing_metadata() {
1570 let _g = setup();
1571 let tmp = tempdir().unwrap();
1572 with_test_home(tmp.path(), || {
1573 let big = "Y".repeat(200 * 1024);
1574 let mut result = ToolResult::success(big)
1575 .with_metadata(serde_json::json!({"prior_key": "prior_value"}));
1576 let path = apply_spillover(&mut result, "call-meta").expect("should spill");
1577
1578 let metadata = result.metadata.expect("metadata present");
1579 // Prior keys survive.
1580 assert_eq!(
1581 metadata
1582 .get("prior_key")
1583 .and_then(serde_json::Value::as_str),
1584 Some("prior_value")
1585 );
1586 // New key added alongside.
1587 assert_eq!(
1588 metadata
1589 .get("spillover_path")
1590 .and_then(serde_json::Value::as_str),
1591 Some(path.display().to_string().as_str())
1592 );
1593 });
1594 }
1595
1596 #[test]
1597 fn apply_spillover_wraps_non_object_metadata_under_prior_key() {
1598 // Defends against a tool whose `metadata` is something
1599 // other than a JSON object (rare — most use the `json!({})`
1600 // pattern — but legal per `serde_json::Value`). The
1601 // spillover writer must add `spillover_path` without losing
1602 // the prior payload.
1603 let _g = setup();
1604 let tmp = tempdir().unwrap();
1605 with_test_home(tmp.path(), || {
1606 let big = "Z".repeat(200 * 1024);
1607 let mut result = ToolResult::success(big).with_metadata(serde_json::json!([
1608 "unexpected",
1609 "array",
1610 "payload"
1611 ]));
1612 let path = apply_spillover(&mut result, "call-arr").expect("should spill");
1613
1614 let metadata = result.metadata.expect("metadata stamped");
1615 // Prior payload re-homed under `_prior`.
1616 let prior = metadata.get("_prior").expect("_prior wrap key present");
1617 assert_eq!(
1618 prior,
1619 &serde_json::json!(["unexpected", "array", "payload"]),
1620 "prior array should round-trip under _prior"
1621 );
1622 // New key alongside.
1623 assert_eq!(
1624 metadata
1625 .get("spillover_path")
1626 .and_then(serde_json::Value::as_str),
1627 Some(path.display().to_string().as_str())
1628 );
1629 });
1630 }
1631
1632 // ── Honest-truncation regressions (v0.9.4) ─────────────────────────────
1633
1634 #[test]
1635 fn truncated_preview_returns_content_unchanged_when_nothing_omitted() {
1636 let original = "line one\nline two\nline three\n";
1637 let preview = truncated_preview(original, "", original, "/tmp/artifact.txt", None);
1638 assert_eq!(preview, original);
1639 assert!(
1640 !preview.contains("of output omitted"),
1641 "must never claim a truncation that did not happen"
1642 );
1643 }
1644
1645 #[test]
1646 fn head_tail_windows_never_overlap() {
1647 // Content smaller than head + tail budgets: the tail window shrinks
1648 // so it starts exactly where the head ends — no byte appears twice.
1649 let content = "x".repeat(10_000);
1650 let (head, tail) = head_tail_windows(&content, 8 * 1024, 4 * 1024);
1651 assert_eq!(head.len(), 8 * 1024);
1652 assert_eq!(tail.len(), 10_000 - 8 * 1024);
1653 assert!(head.len() + tail.len() <= content.len());
1654
1655 // Content larger than both budgets: full windows, exact omission.
1656 let big = "y".repeat(100_000);
1657 let (head, tail) = head_tail_windows(&big, 32 * 1024, 8 * 1024);
1658 assert_eq!(head.len(), 32 * 1024);
1659 assert_eq!(tail.len(), 8 * 1024);
1660
1661 // UTF-8 codepoints are never split at either window edge.
1662 let emoji = "🐳".repeat(5_000); // 20_000 bytes, 4 per codepoint
1663 let (head, tail) = head_tail_windows(&emoji, 8 * 1024 + 1, 4 * 1024 + 2);
1664 assert!(emoji.is_char_boundary(head.len()));
1665 assert!(emoji.is_char_boundary(emoji.len() - tail.len()));
1666 assert!(head.len() + tail.len() <= emoji.len());
1667 }
1668
1669 #[test]
1670 fn adaptive_evidence_passes_through_when_preview_budget_covers_output() {
1671 let _g = setup();
1672 let tmp = tempdir().unwrap();
1673 with_test_home(tmp.path(), || {
1674 // 30_000 bytes → 10_000 estimated tokens → Hybrid band under the
1675 // 32_768-token default, but the 32 KiB + 8 KiB preview budget
1676 // covers the whole output, so nothing is actually omitted.
1677 let raw = "mid\n".repeat(7_500);
1678 assert_eq!(raw.len(), 30_000);
1679 let mut result = ToolResult::success(raw.clone());
1680 let path =
1681 adaptive_spillover(&mut result, "call-covered", "exec_shell", "session-covered");
1682 assert!(path.is_none(), "no artifact when nothing is omitted");
1683 assert_eq!(result.content, raw);
1684 assert!(!result.content.contains("of output omitted"));
1685 assert!(
1686 !tmp.path()
1687 .join(".codewhale/sessions/session-covered/artifacts/art_call-covered.txt")
1688 .exists()
1689 );
1690 });
1691 }
1692
1693 #[test]
1694 fn adaptive_evidence_footer_names_artifact_path_and_recovery() {
1695 let _g = setup();
1696 let tmp = tempdir().unwrap();
1697 with_test_home(tmp.path(), || {
1698 // 120_000 bytes → 40_000 estimated tokens → handle-only band.
1699 let raw = "entry\n".repeat(20_000);
1700 assert_eq!(raw.len(), 120_000);
1701 let mut result = ToolResult::success(raw);
1702 let path =
1703 adaptive_spillover(&mut result, "call-honest", "exec_shell", "session-honest")
1704 .expect("should spill");
1705
1706 // Footer: omitted size + line count, artifact path, recovery line.
1707 assert!(result.content.contains("of output omitted ("));
1708 assert!(result.content.contains(" lines)"));
1709 assert!(result.content.contains("full output at"));
1710 assert!(
1711 result
1712 .content
1713 .contains(&crate::artifacts::format_artifact_relative_path(&path))
1714 );
1715 assert!(result.content.contains(SPILLOVER_RECOVERY_HINT));
1716 assert!(!result.content.contains(SPILLOVER_PREVIEW_HINT));
1717
1718 // Head and tail do not overlap: 16 KiB + 4 KiB handle-only
1719 // windows over a 120_000-byte output.
1720 let metadata = result.metadata.expect("metadata stamped");
1721 assert_eq!(metadata["retained_head_bytes"], 16 * 1024);
1722 assert_eq!(metadata["retained_tail_bytes"], 4 * 1024);
1723 });
1724 }
1725
1726 #[test]
1727 fn apply_spillover_with_artifact_defaults_to_classic_head_tail_spillover() {
1728 // 120_000 bytes exceeds the 100 KiB classic threshold. Without the
1729 // `CODEWHALE_ADAPTIVE_OUTPUT_ROUTING` opt-in the public entry point
1730 // keeps a 32 KiB head + 8 KiB tail and writes the session artifact —
1731 // the adaptive handle-only windows and evidence metadata are opt-in.
1732 let _g = setup();
1733 let tmp = tempdir().unwrap();
1734 with_test_home(tmp.path(), || {
1735 let raw = "entry\n".repeat(20_000);
1736 assert_eq!(raw.len(), 120_000);
1737 let mut result = ToolResult::success(raw.clone());
1738 let path = apply_spillover_with_artifact(
1739 &mut result,
1740 "call-classic",
1741 "exec_shell",
1742 "session-classic",
1743 )
1744 .expect("should spill");
1745
1746 let metadata = result.metadata.expect("metadata stamped");
1747 assert_eq!(metadata["retained_head_bytes"], 32 * 1024);
1748 assert_eq!(metadata["retained_tail_bytes"], 8 * 1024);
1749 assert!(result.content.contains(SPILLOVER_RECOVERY_HINT));
1750 assert!(
1751 !tmp.path()
1752 .join(
1753 ".codewhale/sessions/session-classic/artifacts/art_call-classic.evidence.json"
1754 )
1755 .exists(),
1756 "classic lane publishes no adaptive evidence metadata"
1757 );
1758 assert_eq!(std::fs::read_to_string(&path).unwrap(), raw);
1759 });
1760 }
1761 }
1762
1762 lines RUST