fix(tui): honest large-output truncation + recovery path (#5212)

Dogfood finding (FINISH-0.9.4 appendix #35): large tool results were
elided dishonestly — the model could not tell content was omitted, the
preview could be empty or misleading, thresholds were too aggressive,
and the footer withheld where the full output went.

- large_output_router: raise DEFAULT_LARGE_OUTPUT_THRESHOLD_TOKENS from
  4_096 to 32_768 so ordinary results (file reads, build logs) stay
  inline. Band logic unchanged (Inline <= t/4, Hybrid <= t, HandleOnly).
- truncate: adaptive evidence windows are now Hybrid 32 KiB head + 8 KiB
  tail and HandleOnly 16 KiB head + 4 KiB tail; head/tail never overlap
  (shared head_tail_windows helper). truncated_preview returns content
  unchanged when omitted == 0 — it never claims a truncation that did
  not happen, and the adaptive path declines to publish an artifact in
  that case. The model-facing footer now names the omitted bytes/lines,
  the on-disk artifact path, and a one-line recovery instruction.
  SPILLOVER_PREVIEW_HINT is kept for TUI rendering only.
- file: read_file results stamp metadata evidence_routing=inline; the
  tool self-bounds at 16 KiB behind its own next_start_line contract,
  so the envelope must not double-wrap it. registry respects a
  tool-declared routing instead of overwriting it with the estimate.
- engine context compactor + wire compactor (chat.rs): pass
  evidence-bounded previews through untouched. Re-compacting them
  destroyed the footer and falsely reported "no session-owned artifact
  was recorded".
- history: is_truncated_output_preview also recognises the new footer.

Tests: updated existing assertions to the new intended behavior
(threshold sizes, footer shape, preview budgets; acceptance/PTY probes
now fill both streams to exceed the Hybrid budget with the sentinel in
the envelope-omitted middle). New regressions: omitted==0 passthrough,
head/tail non-overlap incl. UTF-8 edges, footer names artifact path +
recovery line, default threshold is 32_768, evidence previews are not
re-compacted.

cargo fmt --all clean. cargo test -p codewhale-tui: 9624 passed; the 19
bin failures (provider alias/catalog tests) and 2 qa_pty failures
(interactive_init, v091 visual matrix) reproduce identically on base
d53f4f998 and are unrelated.
This commit is contained in:
Hunter Bown
2026-08-03 03:15:21 -07:00
committed by GitHub
parent af9256e0af
commit f0a6898c35
11 changed files with 387 additions and 130 deletions
+15
View File
@@ -1746,6 +1746,21 @@ fn compact_tool_result_for_wire(
};
}
// Content already bounded by the adaptive evidence envelope carries its
// own honest footer: the omitted count, the on-disk artifact path, and a
// recovery instruction. Truncating it again here would destroy that
// recovery contract and falsely report that no session-owned artifact
// was recorded, so pass it through untouched.
if content.contains(crate::tools::truncate::SPILLOVER_RECOVERY_HINT) {
return WireToolResult {
content: content.to_string(),
original_chars,
sent_chars: original_chars,
truncated: false,
deduplicated: false,
};
}
let head = first_chars(content, TOOL_RESULT_HEAD_CHARS);
let tail = last_chars(content, TOOL_RESULT_TAIL_CHARS);
let kept = head.chars().count() + tail.chars().count();
+14
View File
@@ -432,6 +432,20 @@ pub(crate) fn compact_tool_result_for_route(
return String::new();
}
// A result already bounded by the adaptive evidence envelope is an
// honest, context-sized preview whose footer names the artifact path and
// a recovery instruction. Re-compacting it would strip that recovery
// contract and double-truncate the output, so pass it through unchanged.
if output
.metadata
.as_ref()
.and_then(|metadata| metadata.get("evidence_available"))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
{
return raw.to_string();
}
if let Some(summary) = compact_subagent_tool_result_for_context(tool_name, raw) {
return summary;
}
+22
View File
@@ -11544,6 +11544,28 @@ fn v4_keeps_large_file_reads_but_compacts_noisy_shell_output() {
assert!(legacy_context.len() < v4_context.len());
}
#[test]
fn evidence_bounded_preview_is_not_recompacted() {
// The adaptive evidence envelope already produced an honest bounded
// preview (head + footer with the recovery path + tail). The context
// compactor must pass it through untouched, even beyond the 12K hard
// limit — re-compacting would strip the recovery contract.
let content = format!(
"{}\n\n… 19.0 KiB of output omitted (123 lines) — full output at /tmp/art_call.txt; read it back with the read_file tool or with sed line ranges\n\n\n{}",
"h".repeat(32 * 1024),
"t".repeat(8 * 1024)
);
let output = ToolResult::success(content.clone()).with_metadata(json!({
"evidence_available": true,
"truncated": true,
"spillover_path": "/tmp/art_call.txt"
}));
let context = compact_tool_result_for_context("deepseek-v3.2-128k", "Bash", &output);
assert_eq!(context, content);
assert!(context.contains("full output at /tmp/art_call.txt"));
}
#[test]
fn codex_tool_retention_uses_oauth_route_window_not_api_model_window() {
let content = "route-effective context\n".repeat(900);
+12 -3
View File
@@ -185,7 +185,9 @@ impl ToolSpec for ReadFileTool {
let total_lines = contents.lines().count();
if total_lines <= SMALL_FILE_LINES {
return Ok(ToolResult::success(contents));
return Ok(ToolResult::success(contents).with_metadata(json!({
"evidence_routing": "inline"
})));
}
// Small in bytes but too many lines: render the default window
@@ -259,7 +261,9 @@ impl ToolSpec for ReadFileTool {
[NO CONTENT] start_line {start_line} is beyond total_lines {total_lines}.\n\
</file>"
);
return Ok(ToolResult::success(output));
return Ok(ToolResult::success(output).with_metadata(json!({
"evidence_routing": "inline"
})));
}
Ok(render_line_window(
@@ -392,7 +396,12 @@ fn render_line_window(
}
output.push_str("</file>");
ToolResult::success(output)
// The file tool self-bounds at 16 KiB and carries its own continuation
// contract (`next_start_line`), so the large-output spillover envelope
// must never re-wrap a read result with a second, weaker truncation.
ToolResult::success(output).with_metadata(json!({
"evidence_routing": "inline"
}))
}
fn read_image_via_ocr(path: &Path, requested_path: &str) -> Result<ToolResult, ToolError> {
+16 -5
View File
@@ -17,7 +17,13 @@ use crate::tools::spec::ToolResult;
// ── Constants ──────────────────────────────────────────────────────────────────
/// Default token threshold separating hybrid from handle-only evidence.
pub const DEFAULT_LARGE_OUTPUT_THRESHOLD_TOKENS: usize = 4_096;
///
/// 32K tokens (≈96 KiB of text at the 3 chars/token estimate) keeps ordinary
/// tool results — file reads, test runs, build logs up to a few thousand
/// lines — fully inline. Only genuinely large outputs spill to evidence
/// artifacts, where the model-facing preview names the artifact path and how
/// to recover the omitted range.
pub const DEFAULT_LARGE_OUTPUT_THRESHOLD_TOKENS: usize = 32_768;
/// Approximate characters-per-token ratio used for the heuristic estimate.
/// We intentionally choose a conservative value (3 chars/token) so we err
@@ -345,11 +351,16 @@ mod tests {
);
}
#[test]
fn default_threshold_is_32k_tokens() {
assert_eq!(DEFAULT_LARGE_OUTPUT_THRESHOLD_TOKENS, 32_768);
}
#[test]
fn synthesise_above_threshold() {
let router = LargeOutputRouter::default();
// DEFAULT threshold = 4096 tokens; 3 chars/token → 4096*3 = 12288 chars
let big = "a".repeat(13_000);
// DEFAULT threshold = 32768 tokens; 3 chars/token → 32768*3 = 98304 chars
let big = "a".repeat(100_000);
let result = make_result(&big);
assert!(matches!(
router.route("read_file", &result, false),
@@ -360,7 +371,7 @@ mod tests {
#[test]
fn raw_bypass_skips_routing() {
let router = LargeOutputRouter::default();
let big = "a".repeat(13_000);
let big = "a".repeat(100_000);
let result = make_result(&big);
// raw=true → always pass through regardless of size
assert_eq!(
@@ -372,7 +383,7 @@ mod tests {
#[test]
fn adaptive_evidence_cannot_bypass_context_bound_with_raw_flag() {
let router = LargeOutputRouter::default();
let big = make_result(&"a".repeat(13_000));
let big = make_result(&"a".repeat(100_000));
let (routing, _, _) = router.evidence_routing("exec_shell", &big, true);
assert_eq!(routing, EvidenceRouting::HandleOnly);
}
+11 -2
View File
@@ -124,13 +124,22 @@ impl ToolRegistry {
if let Some(router) = ctx.large_output_router.as_ref() {
use crate::tools::large_output_router::{
LargeOutputRouter, RouteDecision, classic_output_routing_enabled,
EvidenceRouting, LargeOutputRouter, RouteDecision, classic_output_routing_enabled,
};
if !classic_output_routing_enabled() {
let (routing, estimated_tokens, threshold) =
let (estimated_routing, estimated_tokens, threshold) =
router.evidence_routing(name, &result, raw_bypass);
let metadata = result.metadata.get_or_insert_with(|| serde_json::json!({}));
if let Some(object) = metadata.as_object_mut() {
// A tool that self-bounds its output behind its own
// recovery contract (e.g. read_file's `next_start_line`
// paging) declares its routing itself; the size estimate
// must not override that and double-wrap the result.
let routing = object
.get("evidence_routing")
.cloned()
.and_then(|value| serde_json::from_value::<EvidenceRouting>(value).ok())
.unwrap_or(estimated_routing);
object.insert(
"evidence_routing".to_string(),
serde_json::to_value(routing)
+12 -7
View File
@@ -13035,12 +13035,16 @@ fn subagent_tool_results_spill_to_disk_and_stay_bounded_inline() {
);
let path = spilled.expect("multi-MB output must spill");
// Model-visible content is a bounded, ordinary preview. Internal
// storage paths and retrieval machinery stay out of the transcript.
assert!(inline.len() <= 4 * 1024);
assert!(inline.contains(crate::tools::truncate::SPILLOVER_PREVIEW_HINT));
// Model-visible content is a bounded, honest preview: the footer
// names the on-disk artifact path and how to read the omitted range
// back. Retrieval machinery stays out of the transcript.
assert!(inline.len() <= 21 * 1024);
assert!(!inline.contains(crate::tools::truncate::SPILLOVER_PREVIEW_HINT));
assert!(inline.contains("of output omitted"));
assert!(inline.contains("full output at"));
assert!(inline.contains(crate::tools::truncate::SPILLOVER_RECOVERY_HINT));
assert!(inline.contains("\n\n"));
assert!(!inline.contains(&path.display().to_string()));
assert!(inline.contains(&path.display().to_string()));
assert!(!inline.contains("Exact evidence retained"));
assert!(!inline.contains("retrieve_tool_result"));
// Full output remains recoverable from disk.
@@ -13070,8 +13074,9 @@ fn subagent_tool_results_spill_to_disk_and_stay_bounded_inline() {
format!("Error: {raw}"),
);
assert!(spilled.is_some());
assert!(bounded_err.len() <= 4 * 1024);
assert!(bounded_err.contains(crate::tools::truncate::SPILLOVER_PREVIEW_HINT));
assert!(bounded_err.len() <= 21 * 1024);
assert!(bounded_err.contains("of output omitted"));
assert!(bounded_err.contains(crate::tools::truncate::SPILLOVER_RECOVERY_HINT));
assert!(!bounded_err.contains("Exact evidence retained"));
assert!(!bounded_err.contains("retrieve_tool_result"));
});
+222 -67
View File
@@ -24,8 +24,8 @@
//! path (`turn_loop.rs`) so any successful tool result over
//! [`SPILLOVER_THRESHOLD_BYTES`] spills to disk and the model
//! receives a bounded plain preview: a [`SPILLOVER_HEAD_BYTES`] head,
//! a short retained tail, and an ordinary footer pointing at the
//! tool details view.
//! a short retained tail, and an honest footer naming the on-disk
//! path of the full output plus a one-line recovery instruction.
//! * Boot prune in `main.rs` deletes files older than
//! [`SPILLOVER_MAX_AGE`].
//!
@@ -330,39 +330,72 @@ pub const SPILLOVER_HEAD_BYTES: usize = 32 * 1024;
/// test failures are not systematically hidden by truncation.
pub const SPILLOVER_TAIL_BYTES: usize = 8 * 1024;
fn retained_tail(content: &str, max_bytes: usize) -> &str {
let floor = content.len().saturating_sub(max_bytes);
let start = (floor..=content.len())
.find(|&index| content.is_char_boundary(index))
.unwrap_or(content.len());
&content[start..]
}
/// Inline head/tail budgets for the adaptive evidence bands. Hybrid results
/// keep a generous 32 KiB head + 8 KiB tail so mid-size outputs stay mostly
/// readable; handle-only results keep a 16 KiB head + 4 KiB tail. The head
/// and tail windows never overlap ([`head_tail_windows`]).
const HYBRID_HEAD_BYTES: usize = 32 * 1024;
const HYBRID_TAIL_BYTES: usize = 8 * 1024;
const HANDLE_ONLY_HEAD_BYTES: usize = 16 * 1024;
const HANDLE_ONLY_TAIL_BYTES: usize = 4 * 1024;
/// Phrase shared by the model-facing preview footer and the TUI expand
/// affordance, so both surfaces agree on where the full output lives.
/// The phrase itself is deliberately ordinary: no handles, paths, or
/// retrieval references.
/// Phrase used only by the TUI expand affordance and the UI-side detection of
/// historical truncated previews. Never emitted into model-facing content:
/// the model cannot open the tool details view, so the model-facing footer
/// carries the artifact path and a recovery instruction instead.
pub const SPILLOVER_PREVIEW_HINT: &str = "view full output in the tool details view";
/// Ordinary footer for a truncated tool result. The full output is retained
/// internally; this text only tells the model the preview is bounded and
/// where the complete output can be seen (the tool details view).
fn spillover_preview_footer(omitted_bytes: usize) -> String {
/// One-line recovery instruction in the model-facing truncation footer. Also
/// used by the TUI to recognise current-format truncated previews.
pub const SPILLOVER_RECOVERY_HINT: &str =
"read it back with the read_file tool or with sed line ranges";
/// Model-facing footer for a truncated tool result. Names how much was
/// omitted (bytes and lines), where the complete output lives on disk, and
/// how the model can read the omitted range back.
fn spillover_preview_footer(
omitted_bytes: usize,
omitted_lines: usize,
recovery_path: &str,
) -> String {
format!(
"… {} of output omitted {SPILLOVER_PREVIEW_HINT}",
"… {} of output omitted ({omitted_lines} lines) — full output at {recovery_path}; {SPILLOVER_RECOVERY_HINT}",
crate::artifacts::format_byte_size(omitted_bytes.try_into().unwrap_or(u64::MAX))
)
}
/// Split `content` into a head of at most `head_bytes` and a tail of at most
/// `tail_bytes` that never overlap: the tail window always starts at or after
/// the head window ends, so no byte of the output appears twice and the
/// omitted count is exact.
fn head_tail_windows(content: &str, head_bytes: usize, tail_bytes: usize) -> (&str, &str) {
let head_end = (0..=head_bytes.min(content.len()))
.rev()
.find(|&index| content.is_char_boundary(index))
.unwrap_or(0);
let tail_floor = content.len().saturating_sub(tail_bytes).max(head_end);
let tail_start = (tail_floor..=content.len())
.find(|&index| content.is_char_boundary(index))
.unwrap_or(content.len());
(&content[..head_end], &content[tail_start..])
}
/// Build the model-facing preview for a truncated tool result: the head, an
/// ordinary footer naming how much was omitted and where the full output can
/// be seen, and a short retained tail. The full output is still retained
/// internally; this is only the conversation-facing shape.
fn truncated_preview(head: &str, tail: &str, original_len: usize) -> String {
let omitted = original_len.saturating_sub(head.len() + tail.len());
/// honest footer naming how much was omitted and where the full output can be
/// read back, and a short retained tail. When the head and tail windows cover
/// the whole output (nothing was actually omitted), the content is returned
/// unchanged — the preview never claims a truncation that did not happen.
fn truncated_preview(head: &str, tail: &str, original: &str, recovery_path: &str) -> String {
let omitted = original.len().saturating_sub(head.len() + tail.len());
if omitted == 0 {
return original.to_string();
}
let omitted_lines = original[head.len()..original.len() - tail.len()]
.lines()
.count();
format!(
"{head}\n\n{}\n\n\n{tail}",
spillover_preview_footer(omitted)
spillover_preview_footer(omitted, omitted_lines, recovery_path)
)
}
@@ -370,9 +403,9 @@ fn truncated_preview(head: &str, tail: &str, original_len: usize) -> String {
/// content exceeds [`SPILLOVER_THRESHOLD_BYTES`], writes the full
/// content to a sibling file under `~/.codewhale/tool_outputs/`,
/// replaces `result.content` with a [`SPILLOVER_HEAD_BYTES`] head
/// plus an ordinary preview footer pointing at the tool details
/// view, and stamps `metadata.spillover_path` so the UI can render
/// its expand annotation.
/// plus a footer naming the spillover path and how to read the
/// omitted range back, and stamps `metadata.spillover_path` so the
/// UI can render its expand annotation.
///
/// Returns the spillover path on success, `None` if no spillover
/// happened (content small enough, error result, write failure).
@@ -392,7 +425,8 @@ pub fn apply_spillover(result: &mut ToolResult, tool_id: &str) -> Option<PathBuf
/// Apply adaptive routing and publish session-scoped exact evidence.
///
/// The default path writes one immutable payload under the origin session and
/// replaces non-inline content with a calm, bounded receipt. The legacy dual
/// replaces non-inline content with a bounded preview whose footer names the
/// artifact path and how to read the omitted range back. The legacy dual
/// spillover behavior is reachable only through the classic rollback switch.
pub fn apply_spillover_with_artifact(
result: &mut ToolResult,
@@ -451,8 +485,12 @@ fn apply_spillover_inner(
return None;
}
};
let (head, path) = outcome;
let tail = retained_tail(&original_content, SPILLOVER_TAIL_BYTES);
let (_head, path) = outcome;
let (head, tail) = head_tail_windows(
&original_content,
SPILLOVER_HEAD_BYTES,
SPILLOVER_TAIL_BYTES,
);
let digest = crate::hashing::sha256_hex(original_content.as_bytes());
let path_str = path.display().to_string();
@@ -490,7 +528,12 @@ fn apply_spillover_inner(
relative_path.clone(),
&original_content,
);
result.content = truncated_preview(&head, tail, original_content.len());
result.content = truncated_preview(
head,
tail,
&original_content,
&absolute_path.display().to_string(),
);
artifact_path = Some((absolute_path, relative_path, record));
}
Err(err) => {
@@ -505,7 +548,7 @@ fn apply_spillover_inner(
}
if artifact_path.is_none() {
result.content = truncated_preview(&head, tail, original_content.len());
result.content = truncated_preview(head, tail, &original_content, &path_str);
}
let metadata = result.metadata.get_or_insert_with(|| serde_json::json!({}));
@@ -658,6 +701,22 @@ fn apply_adaptive_evidence_inner(
}
let original = result.content.clone();
let (head_bytes, tail_bytes) = if routing == EvidenceRouting::Hybrid {
(HYBRID_HEAD_BYTES, HYBRID_TAIL_BYTES)
} else {
(HANDLE_ONLY_HEAD_BYTES, HANDLE_ONLY_TAIL_BYTES)
};
let (head, tail) = head_tail_windows(&original, head_bytes, tail_bytes);
let omitted = original.len().saturating_sub(head.len() + tail.len());
if omitted == 0 {
// The whole output fits inside the preview budget: there is nothing
// to recover, so publishing an artifact and claiming a truncation
// would both be dishonest. Pass the content through unchanged.
return None;
}
let head_len = head.len();
let tail_len = tail.len();
let artifact_id = crate::artifacts::artifact_id_for_tool_call(tool_id);
let relative_path = crate::artifacts::session_artifact_relative_path(&artifact_id);
let digest = crate::hashing::sha256_hex(original.as_bytes());
@@ -736,22 +795,7 @@ fn apply_adaptive_evidence_inner(
relative_path.clone(),
&original,
);
let head_limit = if routing == EvidenceRouting::Hybrid {
8 * 1024
} else {
2 * 1024
};
let tail_limit = if routing == EvidenceRouting::Hybrid {
2 * 1024
} else {
512
};
let head_end = (0..=head_limit.min(original.len()))
.rev()
.find(|index| original.is_char_boundary(*index))
.unwrap_or(0);
let tail = retained_tail(&original, tail_limit);
result.content = truncated_preview(&original[..head_end], tail, original.len());
result.content = truncated_preview(head, tail, &original, &absolute_path.display().to_string());
let metadata = result.metadata.get_or_insert_with(|| serde_json::json!({}));
if let Some(object) = metadata.as_object_mut() {
object.insert(
@@ -772,8 +816,8 @@ fn apply_adaptive_evidence_inner(
object.insert("evidence_available".into(), true.into());
object.insert("truncated".into(), true.into());
object.insert("original_byte_count".into(), artifact.size_bytes.into());
object.insert("retained_head_bytes".into(), head_end.into());
object.insert("retained_tail_bytes".into(), tail.len().into());
object.insert("retained_head_bytes".into(), head_len.into());
object.insert("retained_tail_bytes".into(), tail_len.into());
object.insert(
"artifact_preview".into(),
original.chars().take(200).collect::<String>().into(),
@@ -1092,18 +1136,22 @@ mod tests {
let mut result = ToolResult::success(big.clone());
let path = apply_spillover(&mut result, "call-big").expect("should spill");
// Inline content shrunk to head + plain preview footer.
// Inline content shrunk to head + honest preview footer.
assert!(result.content.len() < big.len());
assert!(
result.content.contains(SPILLOVER_PREVIEW_HINT),
!result.content.contains(SPILLOVER_PREVIEW_HINT),
"the tool-details phrase is a UI affordance, not model-facing"
);
assert!(
result.content.contains("of output omitted"),
"footer missing: {}",
&result.content[result.content.len().saturating_sub(200)..]
);
assert!(
result
.content
.contains("of output omitted — view full output in the tool details view")
);
// The footer tells the model where the full output lives and how
// to read the omitted range back.
assert!(result.content.contains("full output at"));
assert!(result.content.contains(&path.display().to_string()));
assert!(result.content.contains(SPILLOVER_RECOVERY_HINT));
assert!(!result.content.contains("retrieve_tool_result"));
// Full bytes are on disk at the returned path.
@@ -1156,13 +1204,19 @@ mod tests {
.exists(),
"adaptive evidence stores one exact origin-session copy"
);
// The model sees a plain preview with an ordinary footer — no
// artifact handle, no retrieval reference.
assert!(result.content.contains(SPILLOVER_PREVIEW_HINT));
// The model sees a bounded preview with an honest footer: the
// artifact path and a recovery instruction, no retrieval handle.
assert!(!result.content.contains(SPILLOVER_PREVIEW_HINT));
assert!(result.content.contains("\n\n"));
assert!(result.content.contains("of output omitted"));
assert!(result.content.contains("full output at"));
assert!(result.content.contains(SPILLOVER_RECOVERY_HINT));
assert!(
result.content.contains("art_call-big.txt"),
"footer must name the artifact path so the model can recover the output"
);
assert!(!result.content.contains("Exact evidence retained"));
assert!(!result.content.contains("retrieve_tool_result"));
assert!(!result.content.contains("artifacts/art_call-big.txt"));
assert!(
session_artifact
.with_file_name("art_call-big.evidence.json")
@@ -1189,8 +1243,8 @@ mod tests {
Some("session-123")
);
assert_eq!(metadata["original_byte_count"], big.len());
assert!(metadata["retained_head_bytes"].as_u64().unwrap_or(0) <= 2 * 1024);
assert!(metadata["retained_tail_bytes"].as_u64().unwrap_or(0) <= 512);
assert!(metadata["retained_head_bytes"].as_u64().unwrap_or(0) <= 16 * 1024);
assert!(metadata["retained_tail_bytes"].as_u64().unwrap_or(0) <= 4 * 1024);
});
}
@@ -1200,13 +1254,15 @@ mod tests {
let tmp = tempdir().unwrap();
with_test_home(tmp.path(), || {
let sentinel = "DEEP_RAW_SENTINEL";
// Payloads must exceed the 32_768-token (≈96 KiB) handle-only
// threshold so adaptive routing actually spills them.
let success_raw = format!(
"{}{}{}",
"head\n".repeat(2_000),
"head\n".repeat(30_000),
sentinel,
"tail\n".repeat(2_000)
"tail\n".repeat(30_000)
);
let failure_raw = format!("{}{}", "failure\n".repeat(3_000), "FAILURE_END");
let failure_raw = format!("{}{}", "failure\n".repeat(30_000), "FAILURE_END");
let mut success = ToolResult::success(success_raw.clone());
let mut failure = ToolResult::error(failure_raw.clone());
@@ -1235,7 +1291,8 @@ mod tests {
failure_raw.as_bytes()
);
assert!(!success.content.contains(sentinel));
assert!(success.content.len() < 4 * 1024);
// Handle-only preview: 16 KiB head + 4 KiB tail + footer.
assert!(success.content.len() < 21 * 1024);
let success_meta = success.metadata.as_ref().unwrap();
let failure_meta = failure.metadata.as_ref().unwrap();
assert_ne!(
@@ -1405,4 +1462,102 @@ mod tests {
);
});
}
// ── Honest-truncation regressions (v0.9.4) ─────────────────────────────
#[test]
fn truncated_preview_returns_content_unchanged_when_nothing_omitted() {
let original = "line one\nline two\nline three\n";
let preview = truncated_preview(original, "", original, "/tmp/artifact.txt");
assert_eq!(preview, original);
assert!(
!preview.contains("of output omitted"),
"must never claim a truncation that did not happen"
);
}
#[test]
fn head_tail_windows_never_overlap() {
// Content smaller than head + tail budgets: the tail window shrinks
// so it starts exactly where the head ends — no byte appears twice.
let content = "x".repeat(10_000);
let (head, tail) = head_tail_windows(&content, 8 * 1024, 4 * 1024);
assert_eq!(head.len(), 8 * 1024);
assert_eq!(tail.len(), 10_000 - 8 * 1024);
assert!(head.len() + tail.len() <= content.len());
// Content larger than both budgets: full windows, exact omission.
let big = "y".repeat(100_000);
let (head, tail) = head_tail_windows(&big, 32 * 1024, 8 * 1024);
assert_eq!(head.len(), 32 * 1024);
assert_eq!(tail.len(), 8 * 1024);
// UTF-8 codepoints are never split at either window edge.
let emoji = "🐳".repeat(5_000); // 20_000 bytes, 4 per codepoint
let (head, tail) = head_tail_windows(&emoji, 8 * 1024 + 1, 4 * 1024 + 2);
assert!(emoji.is_char_boundary(head.len()));
assert!(emoji.is_char_boundary(emoji.len() - tail.len()));
assert!(head.len() + tail.len() <= emoji.len());
}
#[test]
fn adaptive_evidence_passes_through_when_preview_budget_covers_output() {
let _g = setup();
let tmp = tempdir().unwrap();
with_test_home(tmp.path(), || {
// 30_000 bytes → 10_000 estimated tokens → Hybrid band under the
// 32_768-token default, but the 32 KiB + 8 KiB preview budget
// covers the whole output, so nothing is actually omitted.
let raw = "mid\n".repeat(7_500);
assert_eq!(raw.len(), 30_000);
let mut result = ToolResult::success(raw.clone());
let path = apply_spillover_with_artifact(
&mut result,
"call-covered",
"exec_shell",
"session-covered",
);
assert!(path.is_none(), "no artifact when nothing is omitted");
assert_eq!(result.content, raw);
assert!(!result.content.contains("of output omitted"));
assert!(
!tmp.path()
.join(".codewhale/sessions/session-covered/artifacts/art_call-covered.txt")
.exists()
);
});
}
#[test]
fn adaptive_evidence_footer_names_artifact_path_and_recovery() {
let _g = setup();
let tmp = tempdir().unwrap();
with_test_home(tmp.path(), || {
// 120_000 bytes → 40_000 estimated tokens → handle-only band.
let raw = "entry\n".repeat(20_000);
assert_eq!(raw.len(), 120_000);
let mut result = ToolResult::success(raw);
let path = apply_spillover_with_artifact(
&mut result,
"call-honest",
"exec_shell",
"session-honest",
)
.expect("should spill");
// Footer: omitted size + line count, artifact path, recovery line.
assert!(result.content.contains("of output omitted ("));
assert!(result.content.contains(" lines)"));
assert!(result.content.contains("full output at"));
assert!(result.content.contains(&path.display().to_string()));
assert!(result.content.contains(SPILLOVER_RECOVERY_HINT));
assert!(!result.content.contains(SPILLOVER_PREVIEW_HINT));
// Head and tail do not overlap: 16 KiB + 4 KiB handle-only
// windows over a 120_000-byte output.
let metadata = result.metadata.expect("metadata stamped");
assert_eq!(metadata["retained_head_bytes"], 16 * 1024);
assert_eq!(metadata["retained_tail_bytes"], 4 * 1024);
});
}
}
+6 -4
View File
@@ -1934,11 +1934,13 @@ fn render_spillover_annotation(width: u16) -> Line<'static> {
))
}
/// Detect a truncated-output preview: either the current plain footer or the
/// legacy receipt header still present in older saved sessions. Live cards
/// collapse to the expand affordance for both.
/// Detect a truncated-output preview: the current model-facing footer (which
/// names the artifact path and recovery instruction), the previous plain
/// footer, or the legacy receipt header still present in older saved
/// sessions. Live cards collapse to the expand affordance for all of them.
fn is_truncated_output_preview(content: &str) -> bool {
content.contains(crate::tools::truncate::SPILLOVER_PREVIEW_HINT)
content.contains(crate::tools::truncate::SPILLOVER_RECOVERY_HINT)
|| content.contains(crate::tools::truncate::SPILLOVER_PREVIEW_HINT)
|| content.trim_start().starts_with("[Exact evidence retained")
}
@@ -57,16 +57,24 @@ async fn headless_bash_success_and_failure_are_distinct_bounded_exact_evidence()
(&failure_receipt, FAILURE_SENTINEL),
] {
assert!(
receipt.contains("of output omitted — view full output in the tool details view"),
"model-facing truncation must use the plain preview footer"
receipt.contains("of output omitted"),
"model-facing truncation must state how much was omitted"
);
assert!(
receipt.contains("full output at"),
"model-facing truncation must name the recovery path"
);
assert!(
receipt.contains("/artifacts/"),
"the footer deliberately names the on-disk artifact so the model can read the omitted range back"
);
assert!(!receipt.contains("retrieve_tool_result"));
assert!(!receipt.contains("[Exact evidence retained"));
assert!(!receipt.contains(sentinel));
assert!(!receipt.contains("/artifacts/"));
assert!(
receipt.len() <= 3_200,
"bounded preview must stay within the receipt budget"
receipt.len() <= 42_000,
"bounded preview must stay within the hybrid 32 KiB head + 8 KiB tail receipt budget, got {} bytes",
receipt.len()
);
}
assert_ne!(success_receipt, failure_receipt);
@@ -256,22 +264,27 @@ fn bash_tool_sse(call_id: &str, success: bool) -> String {
].join("")
}
/// Shell fixture that emits enough bytes to force exact-evidence routing: one
/// sentinel line buried at iteration 120 of ~2,800 filler lines. The probe
/// executes through the platform shell — bash on Unix, `cmd /C` on Windows
/// Shell fixture that emits enough bytes to force exact-evidence routing under
/// the 32_768-token default threshold. The Bash adapter self-bounds each
/// stream to ~30 KB, so a single stream would now fit inside the hybrid
/// 32 KiB + 8 KiB preview budget; the probe therefore fills stdout AND stderr
/// (~60 KB combined) so the envelope still omits a middle range. The sentinel
/// rides stderr at filler line 100: deep enough to survive the shell tool's
/// own 22 KB head bound (so the artifact retains it) yet beyond the preview's
/// 32 KiB head (so the model receipt omits it). The probe executes through
/// the platform shell — bash on Unix, `cmd /C` on Windows
/// (#1691) — so each platform needs native syntax to exercise the same
/// routing path.
#[cfg(not(windows))]
fn probe_command(sentinel: &str, prefix: &str, success: bool) -> String {
let trailer = if success { "" } else { "; exit 7" };
let body = format!(
"i=0; while [ \"$i\" -lt 2800 ]; do if [ \"$i\" -eq 120 ]; then printf '%s\\n' '{sentinel}'; fi; printf '{prefix}-%04d-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\\n' \"$i\"; i=$((i + 1)); done{trailer}"
let stdout_loop = format!(
"i=0; while [ \"$i\" -lt 2800 ]; do printf '{prefix}-%04d-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\\n' \"$i\"; i=$((i + 1)); done"
);
if success {
body
} else {
format!("{{ {body}; }} >&2")
}
let stderr_loop = format!(
"j=0; while [ \"$j\" -lt 2800 ]; do if [ \"$j\" -eq 100 ]; then printf '%s\\n' '{sentinel}'; fi; printf '{prefix}-ERR-%04d-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\\n' \"$j\"; j=$((j + 1)); done"
);
format!("{stdout_loop}; {{ {stderr_loop}; }} >&2{trailer}")
}
/// PowerShell syntax: on Windows the shell dispatcher prefers `pwsh.exe`,
@@ -283,26 +296,18 @@ fn probe_command(sentinel: &str, prefix: &str, success: bool) -> String {
/// stderr handle and exiting 7 after the loop.
#[cfg(windows)]
fn probe_command(sentinel: &str, prefix: &str, success: bool) -> String {
let emit = |text: &str| {
if success {
format!("Write-Output {text}")
} else {
format!("[Console]::Error.WriteLine({text})")
}
};
let line = format!(
let stdout_line = format!(
"'{prefix}-{{0}}-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'"
);
let body = format!(
"0..2799 | ForEach-Object {{ if ($_ -eq 120) {{ {} }} else {{ {} }} }}",
emit(&format!("'{sentinel}'")),
emit(&format!("({line} -f $_)"))
let stderr_line = format!(
"'{prefix}-ERR-{{0}}-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'"
);
if success {
body
} else {
format!("{body}; exit 7")
}
let stdout_loop = format!("0..2799 | ForEach-Object {{ Write-Output ({stdout_line} -f $_) }}");
let stderr_loop = format!(
"0..2799 | ForEach-Object {{ if ($_ -eq 100) {{ [Console]::Error.WriteLine('{sentinel}') }}; [Console]::Error.WriteLine(({stderr_line} -f $_)) }}"
);
let trailer = if success { "" } else { "; exit 7" };
format!("{stdout_loop}; {stderr_loop}{trailer}")
}
fn final_sse() -> String {
+20 -10
View File
@@ -2793,8 +2793,15 @@ fn spawn_tool_lifecycle_screen_fixture(
let listener = TcpListener::bind("127.0.0.1:0")?;
listener.set_nonblocking(true)?;
let address = listener.local_addr()?;
// The Bash adapter self-bounds each stream to ~30 KB, so a single stream
// would fit inside the hybrid 32 KiB + 8 KiB preview budget under the
// 32_768-token evidence threshold. Fill stdout AND stderr (~60 KB
// combined) so the envelope still omits a middle range; the sentinel
// rides stderr at filler line 100 — inside the shell tool's own 22 KB
// head bound (so the artifact retains it) but beyond the preview's
// 32 KiB head (so the model receipt omits it).
let shell_command = format!(
"printf 'PTY-TOOL-START\\n'; while [ ! -f {release_signal} ]; do sleep 0.05; done; i=0; while [ \"$i\" -lt 2800 ]; do if [ \"$i\" -eq 120 ]; then printf 'PTY-EVIDENCE-DEEP-SENTINEL\\n'; fi; printf 'PTY-EVIDENCE-%04d-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\\n' \"$i\"; i=$((i + 1)); done; printf 'PTY-TOOL-END\\n'"
"printf 'PTY-TOOL-START\\n'; while [ ! -f {release_signal} ]; do sleep 0.05; done; i=0; while [ \"$i\" -lt 2800 ]; do printf 'PTY-EVIDENCE-%04d-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\\n' \"$i\"; i=$((i + 1)); done; {{ j=0; while [ \"$j\" -lt 2800 ]; do if [ \"$j\" -eq 100 ]; then printf 'PTY-EVIDENCE-DEEP-SENTINEL\\n'; fi; printf 'PTY-EVIDENCE-ERR-%04d-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\\n' \"$j\"; j=$((j + 1)); done; }} >&2; printf 'PTY-TOOL-END\\n'"
);
let replies = [
pty_tool_call_sse(
@@ -2901,22 +2908,25 @@ fn spawn_tool_lifecycle_screen_fixture(
.and_then(|message| message.get("content"))
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
if !bash_result.contains(
"of output omitted — view full output in the tool details view",
) || bash_result.contains("Exact evidence retained")
|| bash_result.contains("art_call_bash_pty")
// Honest bounded-preview contract: the footer states
// the omission and names the on-disk artifact path so
// the model can read the omitted range back; the deep
// sentinel stays out of the inline receipt.
if !bash_result.contains("of output omitted")
|| !bash_result.contains("full output at")
|| !bash_result.contains("art_call_bash_pty.txt")
|| bash_result.contains("Exact evidence retained")
|| bash_result.contains("retrieve_tool_result")
|| bash_result.contains("PTY-EVIDENCE-DEEP-SENTINEL")
|| bash_result.contains("/artifacts/")
{
contract_errors.push(format!(
"final request violated the bounded plain Bash preview contract (preview={}, legacy_receipt={}, handle={}, retrieval_tool={}, deep_sentinel={}, artifact_path={})",
bash_result.contains("of output omitted — view full output in the tool details view"),
"final request violated the honest bounded Bash preview contract (omission={}, path_footer={}, artifact_path={}, legacy_receipt={}, retrieval_tool={}, deep_sentinel={})",
bash_result.contains("of output omitted"),
bash_result.contains("full output at"),
bash_result.contains("art_call_bash_pty.txt"),
bash_result.contains("Exact evidence retained"),
bash_result.contains("art_call_bash_pty"),
bash_result.contains("retrieve_tool_result"),
bash_result.contains("PTY-EVIDENCE-DEEP-SENTINEL"),
bash_result.contains("/artifacts/"),
));
}
}