fix(tools): point every recovery hint at a call the model can actually make

v0.9.3 consolidated the per-action tools behind `File`, `Git`, `Run`, `Web`,
and `Bash`, and `ToolRegistry::resolve` deliberately has no fuzzy step. Every
surviving mention of `read_file`, `edit_file`, `exec_shell`, `exec_shell_wait`,
or `run_verifiers` in a model-facing string is therefore an instruction that
cannot dispatch. Each one costs the turn the model spends finding that out.

The reachable ones, all rewritten to the canonical action form:

- The read-before-edit guard (spec.rs), three separate messages, on the most
  common editing error there is. It named `read_file` and `edit_file` while
  file.rs one guard over already said `File action="read"` correctly.
- `apply_patch`'s two context-match failures — the ordinary way a patch fails.
- `Bash`'s foreground-timeout hint, its background-start receipt, and the
  structured `recommended_tools` / `poll_with` metadata, which handed over a
  literal tool list. The receipt now also carries the `task_id` the wait call
  needs, so the suggested call is complete rather than merely correct.
- `load_skill`'s description and the sibling-file list it prints on every
  successful load; `handle_read`'s and `update_goal`'s schema prose; `rlm`'s
  url parameter.

Truncation was the worst of them. `SPILLOVER_RECOVERY_HINT` offered three
routes and all three were dead: `read_file` is not model-visible, `File
action="read"` on an artifact under ~/.codewhale/sessions is refused as a path
escape, and `sed` through `Bash` reads outside the workspace too — while
`retrieve_tool_result`, which is model-visible and exists for exactly this,
went unmentioned. The web overflow path in tools/web/overflow.rs already got
this right. Adaptive spillover now hands over the `art_<id>` that resolves,
with the modes worth knowing. Legacy global spillover deliberately does not:
its ownership sidecar is allowed to fail to publish, so promising retrieval
there would just be a fourth dead route — it says plainly that no tool call
reaches that copy and names what does work instead.

Two existing tests asserted the absence of `retrieve_tool_result` in truncated
output. That assertion encoded the withholding rather than a constraint: both
paths spill through `apply_spillover_with_artifact` into a session artifact the
retrieval tool resolves. Updated, with the reasoning recorded at the assertion.

Also here because it is the same file: `Bash` was the only action wrapper whose
catch-all fell through to its most dangerous branch — `{"action":"kill",
"command":...}` ran the command instead of cancelling. It now refuses like its
siblings, and the `timeout_ms` description states all three per-action defaults
instead of only `run`'s, so a model omitting it on `wait` no longer silently
gets 30s where the schema promised 120s.
This commit is contained in:
Hmbown
2026-08-04 02:11:46 -07:00
parent 687d865658
commit 33e59b9b94
11 changed files with 269 additions and 61 deletions
+2 -2
View File
@@ -1339,7 +1339,7 @@ fn format_hunk_no_match_error(
let expected_preview = preview_expected_lines(hunk, HUNK_PREVIEW_LINES).join("\n");
let file_preview = snippet_around(lines, *adjusted_line, SNIPPET_RADIUS).join("\n");
format!(
"could not find matching context near line {expected_line} (searched around line {adjusted_line} with offset {offset:+} and fuzz up to {max_fuzz}). Expected context preview:\n{expected_preview}\nFile snippet near line {adjusted_line}:\n{file_preview}\nHints: the line numbers may be stale after earlier edits — call read_file to re-check the current contents, ensure the patch matches the file, increase `fuzz`, or regenerate the patch."
"could not find matching context near line {expected_line} (searched around line {adjusted_line} with offset {offset:+} and fuzz up to {max_fuzz}). Expected context preview:\n{expected_preview}\nFile snippet near line {adjusted_line}:\n{file_preview}\nHints: the line numbers may be stale after earlier edits — call File with action=\"read\" to re-check the current contents, ensure the patch matches the file, increase `fuzz`, or regenerate the patch."
)
}
ApplyHunkError::ContextAmbiguous {
@@ -1352,7 +1352,7 @@ fn format_hunk_no_match_error(
.collect::<Vec<_>>()
.join(", ");
format!(
"could not find matching context near line {expected_line}: the hunk's context appears at multiple locations (lines {candidates}), and the line numbers may be stale after earlier edits, so it is not safe to relocate automatically. Hints: call read_file to inspect the candidate locations above, then regenerate the patch with more surrounding context lines that uniquely identify the target block."
"could not find matching context near line {expected_line}: the hunk's context appears at multiple locations (lines {candidates}), and the line numbers may be stale after earlier edits, so it is not safe to relocate automatically. Hints: call File with action=\"read\" to inspect the candidate locations above, then regenerate the patch with more surrounding context lines that uniquely identify the target block."
)
}
}
+6 -2
View File
@@ -1139,7 +1139,10 @@ async fn edit_file_requires_prior_read() {
.expect_err("edit without read should fail");
let message = err.to_string();
assert!(message.contains("not been read"), "{message}");
assert!(message.contains("read_file"), "{message}");
// The recovery has to be spelled as a call the model can make: `read_file`
// was retired in v0.9.3 and the registry has no fuzzy resolve step.
assert!(message.contains(r#"File with action="read""#), "{message}");
assert!(!message.contains("read_file"), "{message}");
let unchanged = fs::read_to_string(&test_file).expect("read");
assert_eq!(unchanged, "hello world");
@@ -1164,7 +1167,8 @@ async fn edit_file_rejects_stale_prior_read() {
.expect_err("stale read should fail");
let message = err.to_string();
assert!(message.contains("changed since"), "{message}");
assert!(message.contains("read_file"), "{message}");
assert!(message.contains(r#"File with action="read""#), "{message}");
assert!(!message.contains("read_file"), "{message}");
let unchanged = fs::read_to_string(&test_file).expect("read");
assert_eq!(unchanged, "alpha beta gamma");
+1 -1
View File
@@ -795,7 +795,7 @@ impl ToolSpec for UpdateGoalTool {
},
"verification": {
"type": "object",
"description": "Required when status is complete or not_achieved. A verifier-as-judge receipt from a concrete check, such as run_verifiers or an equivalent project-specific gate.",
"description": "Required when status is complete or not_achieved. A verifier-as-judge receipt from a concrete check, such as Run action=\"verifiers\" or an equivalent project-specific gate.",
"properties": {
"status": {
"type": "string",
+1 -1
View File
@@ -189,7 +189,7 @@ impl ToolSpec for HandleReadTool {
as RLM sessions or sub-agents. This does not read artifact ids \
(`art_...`), tool-call ids (`call_...`), SHA refs, or files; use \
retrieve_tool_result for spilled tool results/artifacts and \
read_file for workspace files. Provide \
File action=\"read\" for workspace files. Provide \
exactly one projection: `slice` for char/line slices, `range` for \
one-based line ranges, `count` for metadata counts, or `jsonpath` \
for a small JSON-path projection. This retrieves from the handle's \
+2 -2
View File
@@ -224,7 +224,7 @@ impl ToolSpec for RlmTool {
},
"url": {
"type": "string",
"description": "HTTP/HTTPS URL to fetch through fetch_url and load. (action=open)"
"description": "HTTP/HTTPS URL to fetch (through the same path as Web action=\"fetch\") and load. (action=open)"
},
"session_object": {
"type": "string",
@@ -683,7 +683,7 @@ fn legacy_action_schema(action: &str) -> Value {
},
"url": {
"type": "string",
"description": "HTTP/HTTPS URL to fetch through fetch_url and load."
"description": "HTTP/HTTPS URL to fetch (through the same path as Web action=\"fetch\") and load."
},
"session_object": {
"type": "string",
+24 -15
View File
@@ -2464,9 +2464,9 @@ use crate::tools::spec::{
use async_trait::async_trait;
use serde_json::json;
const FOREGROUND_TIMEOUT_RECOVERY_HINT: &str = "Foreground exec_shell is for bounded commands. \
The timed-out process was killed; rerun long work with task_shell_start or exec_shell with \
background: true, then poll with task_shell_wait or exec_shell_wait.";
const FOREGROUND_TIMEOUT_RECOVERY_HINT: &str = "Foreground Bash is for bounded commands. \
The timed-out process was killed; rerun long work as Bash action=\"run\" background=true, \
then poll with Bash action=\"wait\" task_id=\"<id>\".";
const MACOS_PROVENANCE_HINT: &str = "Docker buildx failed to update its activity file due to a macOS \
com.apple.provenance restriction. Files created by Docker Desktop's signed process carry a \
@@ -2939,7 +2939,7 @@ impl ToolSpec for BashTool {
},
"timeout_ms": {
"type": "integer",
"description": "Timeout in milliseconds (default: 120000, max: 600000)"
"description": "Timeout in milliseconds. The default depends on the action: action=run 120000 (capped at 600000), action=wait 30000, action=interact 1000."
},
"background": {
"type": "boolean",
@@ -3032,7 +3032,18 @@ impl ToolSpec for BashTool {
"wait" => return self.execute_wait(&input, context).await,
"interact" => return self.execute_interact(&input, context).await,
"cancel" => return self.execute_cancel(&input, context).await,
_ => {}
"run" => {}
// Bash was the only action wrapper whose catch-all fell through to
// its most dangerous branch: `{"action":"kill", "command":…}` ran
// the command instead of cancelling, and a mis-cased "Cancel" did
// the same. Every sibling (`File`, `Git`, `Web`, `Run`) already
// refuses an unknown action; the tool that executes arbitrary code
// should not be the lenient one.
other => {
return Err(ToolError::invalid_input(format!(
"Unknown Bash action \"{other}\"; nothing was run. Pass one of: run, wait, interact, cancel."
)));
}
}
let command = required_str(&input, "command")?;
match context.shell_policy {
@@ -3411,11 +3422,11 @@ impl ToolSpec for BashTool {
} else if result.status == ShellStatus::Running {
if backgrounded_foreground {
format!(
"Foreground shell wait moved to /jobs: {task_id_str}\n\nReturns immediately; completion is delivered to the model as an internal runtime event and shown in task/status state. Keep working; call exec_shell_wait only if you need early output, final output, or wait=true at a true dependency."
"Foreground shell wait moved to /jobs: {task_id_str}\n\nReturns immediately; completion is delivered to the model as an internal runtime event and shown in task/status state. Keep working; call Bash action=\"wait\" task_id=\"{task_id_str}\" only if you need early output, final output, or wait=true at a true dependency."
)
} else {
format!(
"Background task started: {task_id_str}\n\nReturns immediately; completion is delivered to the model as an internal runtime event and shown in task/status state. Keep working; call exec_shell_wait only if you need early output, final output, or wait=true at a true dependency."
"Background task started: {task_id_str}\n\nReturns immediately; completion is delivered to the model as an internal runtime event and shown in task/status state. Keep working; call Bash action=\"wait\" task_id=\"{task_id_str}\" only if you need early output, final output, or wait=true at a true dependency."
)
}
} else if result.status == ShellStatus::Killed && was_cancelled {
@@ -3508,14 +3519,12 @@ impl ToolSpec for BashTool {
metadata["foreground_timeout_recovery"] = json!({
"process_killed": true,
"hint": FOREGROUND_TIMEOUT_RECOVERY_HINT,
"recommended_tools": [
"task_shell_start",
"task_shell_wait",
"exec_shell",
"exec_shell_wait"
],
"exec_shell_background": true,
"poll_with": ["task_shell_wait", "exec_shell_wait"]
"recommended_tools": ["Bash", "task_shell_start", "task_shell_wait"],
"rerun_as": {"tool": "Bash", "action": "run", "background": true},
"poll_with": [
{"tool": "Bash", "action": "wait"},
{"tool": "task_shell_wait"}
]
});
}
if let Some(hint) = network_restricted_hint {
+92 -11
View File
@@ -1423,8 +1423,16 @@ async fn test_exec_shell_foreground_timeout_guides_background_rerun() {
.expect("execute");
assert!(!result.success);
assert!(result.content.contains("task_shell_start"));
assert!(result.content.contains("background: true"));
// The rerun instruction has to be spelled in the canonical action form:
// `exec_shell` / `task_shell_start` are not both dispatchable, and the
// model can only reach the shell through `Bash`.
assert!(
result
.content
.contains("Bash action=\"run\" background=true")
);
assert!(result.content.contains("Bash action=\"wait\""));
assert!(!result.content.contains("exec_shell"));
assert!(result.content.contains("process killed"));
let meta = result.metadata.expect("metadata");
assert_eq!(meta.get("status").and_then(Value::as_str), Some("TimedOut"));
@@ -1433,17 +1441,28 @@ async fn test_exec_shell_foreground_timeout_guides_background_rerun() {
.expect("timeout recovery metadata");
assert_eq!(
recovery
.get("exec_shell_background")
.get("rerun_as")
.and_then(|rerun| rerun.get("background"))
.and_then(Value::as_bool),
Some(true)
);
assert!(
assert_eq!(
recovery
.get("hint")
.and_then(Value::as_str)
.unwrap_or_default()
.contains("exec_shell_wait")
.get("rerun_as")
.and_then(|rerun| rerun.get("tool"))
.and_then(Value::as_str),
Some("Bash")
);
let hint = recovery
.get("hint")
.and_then(Value::as_str)
.unwrap_or_default();
assert!(hint.contains("Bash action=\"wait\""), "{hint}");
assert!(!hint.contains("exec_shell"), "{hint}");
// The structured tool list is read by the model too; it must not hand
// over names the registry does not resolve.
let recommended = recovery.to_string();
assert!(!recommended.contains("exec_shell"), "{recommended}");
}
#[test]
@@ -1528,9 +1547,15 @@ async fn test_exec_shell_foreground_can_move_to_background() {
.content
.contains("Foreground shell wait moved to /jobs")
);
// The detach message points the model at the wait tool for early output
// (the cancel-tool reference was reworded to `exec_shell_wait`).
assert!(result.content.contains("exec_shell_wait"));
// The detach message points the model at the wait action for early
// output, and hands over the task_id it needs to make that call.
assert!(
result.content.contains("Bash action=\"wait\""),
"{}",
result.content
);
assert!(result.content.contains("task_id="), "{}", result.content);
assert!(!result.content.contains("exec_shell"), "{}", result.content);
let meta = result.metadata.expect("metadata");
assert_eq!(meta.get("status").and_then(Value::as_str), Some("Running"));
@@ -2314,3 +2339,59 @@ async fn kill_returns_promptly_when_escaped_descendant_holds_pipe_open() {
}
assert!(wait_for_shell_pid_exit(grandchild));
}
/// `Bash` was the only action wrapper whose catch-all fell through to its most
/// dangerous branch: an unrecognised action ran the command instead.
#[tokio::test]
async fn unknown_bash_action_is_refused_instead_of_running_the_command() {
let workspace = tempdir().expect("workspace");
let context = ToolContext::new(workspace.path().to_path_buf());
let marker = workspace.path().join("should-not-exist");
let error = BashTool::new("Bash")
.execute(
json!({
"action": "kill",
"command": format!("touch {}", marker.display()),
}),
&context,
)
.await
.expect_err("unknown action must be refused");
let message = error.to_string();
assert!(message.contains("Unknown Bash action"), "{message}");
assert!(message.contains("kill"), "{message}");
assert!(
message.contains("run, wait, interact, cancel"),
"must name the actions that dispatch: {message}"
);
assert!(!marker.exists(), "the command must not have run");
}
/// Every hint in this file has to name a tool the model can actually call.
/// `exec_shell` / `exec_shell_wait` were retired in v0.9.3.
#[test]
fn shell_recovery_hints_name_only_dispatchable_tools() {
assert!(!FOREGROUND_TIMEOUT_RECOVERY_HINT.contains("exec_shell"));
assert!(FOREGROUND_TIMEOUT_RECOVERY_HINT.contains("Bash"));
assert!(FOREGROUND_TIMEOUT_RECOVERY_HINT.contains("action=\"wait\""));
}
/// One documented default hid three real ones: `wait` uses 30s and
/// `interact` 1s, so a model omitting `timeout_ms` on `wait` got a quarter of
/// the timeout the schema promised.
#[test]
fn timeout_ms_description_covers_every_action_default() {
let schema = BashTool::new("Bash").input_schema();
let description = schema["properties"]["timeout_ms"]["description"]
.as_str()
.expect("timeout_ms description");
for expected in ["120000", "600000", "30000", "1000"] {
assert!(
description.contains(expected),
"missing {expected}: {description}"
);
}
}
+2 -2
View File
@@ -40,7 +40,7 @@ impl ToolSpec for LoadSkillTool {
fn description(&self) -> &'static str {
"Load a skill (SKILL.md body + companion file list) into the next turn's context. \
Use this when the user names a skill or the task clearly matches a skill listed in the system prompt's `## Skills` section. Faster than read_file + list_dir."
Use this when the user names a skill or the task clearly matches a skill listed in the system prompt's `## Skills` section. Faster than File action=\"read\" plus File action=\"list\"."
}
fn input_schema(&self) -> Value {
@@ -268,7 +268,7 @@ fn format_skill_body(skill: &Skill) -> String {
if !companions.is_empty() {
out.push_str("\n## Companion files\n\n");
out.push_str(
"Sibling files in the skill directory. Use `read_file` to open them when the task requires.\n\n",
"Sibling files in the skill directory. Open one with File action=\"read\" when the task requires it; a skill stored outside the workspace has to be read through Bash instead.\n\n",
);
for path in &companions {
out.push_str(&format!("- `{}`\n", path.display()));
+7 -7
View File
@@ -802,25 +802,25 @@ impl ToolContext {
let Some(prior) = prior else {
return Err(ToolError::execution_failed(format!(
"Refusing edit_file for {} because it has not been read in this session. \
Recovery: call read_file with path=\"{requested_path}\" to inspect the current contents, \
then retry edit_file with a unique search string.",
"Refusing File action=\"edit\" for {} because it has not been read in this session. \
Recovery: call File with action=\"read\" path=\"{requested_path}\" to inspect the current contents, \
then retry File action=\"edit\" with a unique search string.",
path.display()
)));
};
let current = file_read_snapshot(path).map_err(|e| {
ToolError::execution_failed(format!(
"Refusing edit_file for {} because the file could not be checked for staleness ({e}). \
Recovery: call read_file with path=\"{requested_path}\" again, then retry edit_file.",
"Refusing File action=\"edit\" for {} because the file could not be checked for staleness ({e}). \
Recovery: call File with action=\"read\" path=\"{requested_path}\" again, then retry File action=\"edit\".",
path.display()
))
})?;
if current != prior {
return Err(ToolError::execution_failed(format!(
"Refusing edit_file for {} because it changed since the last read_file call. \
Recovery: call read_file with path=\"{requested_path}\" again and retry with the current contents.",
"Refusing File action=\"edit\" for {} because it changed since the last File action=\"read\" call. \
Recovery: call File with action=\"read\" path=\"{requested_path}\" again and retry with the current contents.",
path.display()
)));
}
+10 -4
View File
@@ -13222,8 +13222,11 @@ 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, 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.
// names the on-disk artifact path and the call that reads the omitted
// range back. `bound_subagent_tool_result` spills through
// `apply_spillover_with_artifact`, so the bytes land in a session
// artifact that `retrieve_tool_result` resolves — withholding the
// handle only cost the model the turn it spent rediscovering it.
assert!(inline.len() <= 21 * 1024);
assert!(!inline.contains(crate::tools::truncate::SPILLOVER_PREVIEW_HINT));
assert!(inline.contains("of output omitted"));
@@ -13232,7 +13235,7 @@ fn subagent_tool_results_spill_to_disk_and_stay_bounded_inline() {
assert!(inline.contains("\n\n"));
assert!(inline.contains(&path.display().to_string()));
assert!(!inline.contains("Exact evidence retained"));
assert!(!inline.contains("retrieve_tool_result"));
assert!(inline.contains("retrieve_tool_result"), "{inline}");
// Full output remains recoverable from disk.
let on_disk = std::fs::read_to_string(&path).expect("spill file readable");
assert_eq!(on_disk.len(), raw_len);
@@ -13264,7 +13267,10 @@ fn subagent_tool_results_spill_to_disk_and_stay_bounded_inline() {
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"));
assert!(
bounded_err.contains("retrieve_tool_result"),
"{bounded_err}"
);
});
}
+122 -14
View File
@@ -345,10 +345,40 @@ const HANDLE_ONLY_TAIL_BYTES: usize = 4 * 1024;
/// carries the artifact path and a recovery instruction instead.
pub const SPILLOVER_PREVIEW_HINT: &str = "view full output in the tool details view";
/// 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";
/// Sentinel phrase the TUI matches on to recognise a current-format truncated
/// preview. It must stay a literal substring of every footer variant.
pub const SPILLOVER_RECOVERY_HINT: &str = "omitted range recovery:";
/// Model-facing recovery instruction for a truncated tool result.
///
/// The previous text — "read it back with the read_file tool or with sed line
/// ranges" — offered three routes and **all three were dead**: `read_file` is
/// not model-visible (only `File` is), `File action="read"` on an artifact
/// under `~/.codewhale/sessions/` is refused as a path escape, and `sed`
/// through `Bash` reads outside the workspace too. Meanwhile
/// `retrieve_tool_result` — model-visible, purpose-built, and already named
/// correctly by the web overflow path in `tools/web/overflow.rs` — went
/// unmentioned.
///
/// The distinction that matters is retrievability, not tidiness. An adaptive
/// session artifact carries an `art_<id>` the retrieval tool resolves, so name
/// it. A legacy global spillover is authorized by an ownership sidecar whose
/// write is allowed to fail (see [`publish_legacy_spillover_ownership`]), so
/// promising retrieval there would just be a fourth dead route; say plainly
/// that there is no tool call for it and name what does work instead.
fn spillover_recovery_instruction(retrieval_ref: Option<&str>) -> String {
match retrieval_ref {
Some(reference) => format!(
"{SPILLOVER_RECOVERY_HINT} call retrieve_tool_result with ref=\"{reference}\" \
(mode=\"tail\" for the end, mode=\"lines\" with lines=\"120-160\" for a range, \
mode=\"query\" with query=\"\" to search it)"
),
None => format!(
"{SPILLOVER_RECOVERY_HINT} no tool call reaches this copy — re-run the command \
with narrower output (a tighter filter, or head/tail) if you need the rest"
),
}
}
/// Model-facing footer for a truncated tool result. Names how much was
/// omitted (bytes and lines), where the complete output lives on disk, and
@@ -357,10 +387,12 @@ fn spillover_preview_footer(
omitted_bytes: usize,
omitted_lines: usize,
recovery_path: &str,
retrieval_ref: Option<&str>,
) -> String {
format!(
"… {} 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))
"… {} of output omitted ({omitted_lines} lines) — full output at {recovery_path}; {}",
crate::artifacts::format_byte_size(omitted_bytes.try_into().unwrap_or(u64::MAX)),
spillover_recovery_instruction(retrieval_ref)
)
}
@@ -385,7 +417,13 @@ fn head_tail_windows(content: &str, head_bytes: usize, tail_bytes: usize) -> (&s
/// 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 {
fn truncated_preview(
head: &str,
tail: &str,
original: &str,
recovery_path: &str,
retrieval_ref: Option<&str>,
) -> String {
let omitted = original.len().saturating_sub(head.len() + tail.len());
if omitted == 0 {
return original.to_string();
@@ -395,7 +433,7 @@ fn truncated_preview(head: &str, tail: &str, original: &str, recovery_path: &str
.count();
format!(
"{head}\n\n{}\n\n\n{tail}",
spillover_preview_footer(omitted, omitted_lines, recovery_path)
spillover_preview_footer(omitted, omitted_lines, recovery_path, retrieval_ref)
)
}
@@ -533,6 +571,7 @@ fn apply_spillover_inner(
tail,
&original_content,
&absolute_path.display().to_string(),
Some(artifact_id.as_str()),
);
artifact_path = Some((absolute_path, relative_path, record));
}
@@ -548,7 +587,9 @@ fn apply_spillover_inner(
}
if artifact_path.is_none() {
result.content = truncated_preview(head, tail, &original_content, &path_str);
// Legacy fallback: no session artifact was written, so there is no
// `art_<id>` ref to hand over — only the on-disk path.
result.content = truncated_preview(head, tail, &original_content, &path_str, None);
}
let metadata = result.metadata.get_or_insert_with(|| serde_json::json!({}));
@@ -795,7 +836,13 @@ fn apply_adaptive_evidence_inner(
relative_path.clone(),
&original,
);
result.content = truncated_preview(head, tail, &original, &absolute_path.display().to_string());
result.content = truncated_preview(
head,
tail,
&original,
&absolute_path.display().to_string(),
Some(artifact_id.as_str()),
);
let metadata = result.metadata.get_or_insert_with(|| serde_json::json!({}));
if let Some(object) = metadata.as_object_mut() {
object.insert(
@@ -900,6 +947,52 @@ mod tests {
.unwrap_or_else(|e| e.into_inner())
}
/// The old hint named `read_file` (unregistered), `File action="read"`
/// on a path outside the workspace (refused as a path escape), and `sed`
/// through `Bash` (also outside the workspace) — three dead routes — while
/// never naming `retrieve_tool_result`, which is model-visible and exists
/// for exactly this.
#[test]
fn truncation_footer_names_a_recovery_route_that_works() {
let footer = spillover_preview_footer(
4096,
120,
"/tmp/artifacts/art_call-1.txt",
Some("art_call-1"),
);
assert!(footer.contains("retrieve_tool_result"), "{footer}");
assert!(footer.contains("ref=\"art_call-1\""), "{footer}");
assert!(!footer.contains("read_file"), "{footer}");
assert!(!footer.contains("sed"), "{footer}");
}
/// The legacy global copy has no guaranteed authorization sidecar, so the
/// footer must not invent a fourth dead route — but it still must not name
/// the three it used to.
#[test]
fn truncation_footer_without_an_artifact_promises_nothing_it_cannot_deliver() {
let footer = spillover_preview_footer(4096, 120, "/tmp/tool_outputs/call-1.txt", None);
assert!(
footer.contains("no tool call reaches this copy"),
"{footer}"
);
assert!(!footer.contains("retrieve_tool_result"), "{footer}");
assert!(!footer.contains("read_file"), "{footer}");
assert!(!footer.contains("sed"), "{footer}");
}
/// The TUI keys its "this preview was truncated" detection off the shared
/// constant, so it has to stay a literal substring of every variant.
#[test]
fn every_footer_variant_carries_the_ui_detection_marker() {
for reference in [Some("art_call-1"), None] {
let footer = spillover_preview_footer(4096, 120, "/tmp/x.txt", reference);
assert!(footer.contains(SPILLOVER_RECOVERY_HINT), "{footer}");
}
}
#[test]
fn with_test_home_overrides_storage_roots_without_home_resolution() {
let _g = setup();
@@ -1152,7 +1245,13 @@ mod tests {
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"));
assert!(
!result.content.contains("retrieve_tool_result"),
"legacy spillover ownership can fail to publish; promising \
retrieval here would be another dead route"
);
assert!(!result.content.contains("read_file"));
assert!(!result.content.contains("sed"));
// Full bytes are on disk at the returned path.
assert!(path.exists(), "spillover file missing: {path:?}");
@@ -1205,7 +1304,7 @@ mod tests {
"adaptive evidence stores one exact origin-session copy"
);
// The model sees a bounded preview with an honest footer: the
// artifact path and a recovery instruction, no retrieval handle.
// artifact path plus the retrieval call that actually resolves it.
assert!(!result.content.contains(SPILLOVER_PREVIEW_HINT));
assert!(result.content.contains("\n\n"));
assert!(result.content.contains("of output omitted"));
@@ -1216,7 +1315,16 @@ mod tests {
"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("retrieve_tool_result"),
"a session artifact is retrievable; the footer must say so: {}",
result.content
);
assert!(
result.content.contains("ref=\"art_call-big\""),
"the footer must hand over a ref that resolves: {}",
result.content
);
assert!(
session_artifact
.with_file_name("art_call-big.evidence.json")
@@ -1468,7 +1576,7 @@ mod tests {
#[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");
let preview = truncated_preview(original, "", original, "/tmp/artifact.txt", None);
assert_eq!(preview, original);
assert!(
!preview.contains("of output omitted"),