fix(runtime): record a failed tool call as its own error result

A tool call that failed (ToolCallComplete Err) was persisted with only
status=failed and detail, never tool_result_for/is_error. After a runtime
restart the rebuilt transcript held a function_call with no output and the
provider rejected every later turn (`No tool output found for tool call`,
#6803 on 0.10.0). 9ddf26bcc6 already repairs such records on rebuild
(unanswered_call_result); this records the failure correctly at the
source, matching how a success:false result is stored. Old stores and
interrupted/canceled calls still go through the existing repair, so there
is still exactly one repair mechanism.

Regression test: runtime_threads::tests::execution_identity::
a_failed_tool_call_records_its_result_and_replays_after_restart (persist
a failed read, reopen the manager, restore messages, assert one ToolUse
and one matching is_error ToolResult).

Validation: cargo test -p codewhale-tui --lib --
runtime_threads::tests::execution_identity
runtime_threads::tests::monitor_preserves_admitted_correlation_and_redacts_private_answers:
4 passed, 0 failed. With the source hunk reverted, the new test fails
(0 passed; 1 failed). rustfmt --check clean.

Refs #6803

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-04 18:57:04 -07:00
1 parent da9f3f6480
commit 8b20d48d9a
2 files changed
+142 -3

No files matched your search

+18 -3
View File
@@ -1279,9 +1279,12 @@ fn projects_tool_history(item: &TurnItemRecord) -> bool {
/// The result a rebuilt history must give a tool call whose outcome the turn
/// store never recorded, or `None` when nothing is missing.
///
/// A call that failed, was interrupted, or was canceled can be persisted as a
/// single `tool_call` item that carries the call alone: the failure text lives
/// on the item's own `detail`, and no `tool_result_for` item follows. Rebuilding
/// A call that was interrupted or canceled can be persisted as a single
/// `tool_call` item that carries the call alone, and so can a call that failed
/// under a runtime older than #6803, which kept the failure text on the item's
/// own `detail` without marking it `tool_result_for`. (A failure recorded now
/// carries that marker, so the record answers it like any completed call and
/// nothing is missing here.) Rebuilding
/// only the call leaves it unanswered, which a provider rejects outright —
/// `No tool output found for tool call …`. The missing result is also what
/// stopped the first of two responses from flushing, so the rebuild glued them
@@ -16694,6 +16697,18 @@ impl RuntimeThreadManager {
item.summary =
summarize_text(&format!("{name} failed: {err}"), SUMMARY_LIMIT);
item.detail = Some(err);
// The engine answered this call with an error
// result the model received, so the record
// holds that answer the same way a completed
// or `success: false` call holds its own: the
// started identity stays, `detail` is the
// result, and the item is marked as the call's
// result. Without the marker a restart rebuilt
// a call nothing answered (#6803).
if let Some(Value::Object(meta)) = item.metadata.as_mut() {
meta.insert("tool_result_for".to_string(), json!(id));
meta.insert("is_error".to_string(), json!(true));
}
}
}
self.store.save_item(&item)?;
+124
View File
@@ -22518,6 +22518,130 @@ mod execution_identity {
);
Ok(())
}
/// #6803: a tool that fails — a `read` of a missing file — is answered
/// with an error result the model receives. That answer must live in the
/// record itself (`tool_result_for` and `is_error` on the failed item, as a
/// completed call records its own), so a runtime restarted on the same
/// home rebuilds the call together with its result rather than a
/// `function_call` the provider rejects with `No tool output found`.
#[tokio::test]
async fn a_failed_tool_call_records_its_result_and_replays_after_restart() -> Result<()> {
let runtime = test_runtime_dir();
let manager = test_manager(runtime.clone())?;
let thread = manager.create_thread(Default::default()).await?;
let mut harness = install_mock_engine(&manager, &thread.id).await;
let turn = manager
.start_turn(
&thread.id,
StartTurnRequest {
prompt: "read a file that is not there".into(),
..Default::default()
},
)
.await?;
assert!(matches!(
harness.rx_op.recv().await,
Some(Op::SendMessage(_))
));
harness
.tx_event
.send(EngineEvent::TurnStarted {
turn_id: turn.id.clone(),
created_at: Utc::now(),
route: None,
submission_id: None,
})
.await?;
harness
.tx_event
.send(EngineEvent::ToolCallStarted {
id: "host-read".into(),
name: "read".into(),
input: json!({"path": "missing.txt"}),
model_call: Some(ModelToolCall {
provider_id: "call_00_missing".into(),
caller: None,
thought_signature: None,
}),
})
.await?;
harness
.tx_event
.send(EngineEvent::ToolCallComplete {
id: "host-read".into(),
name: "read".into(),
result: Err(crate::tools::spec::ToolError::execution_failed(
"missing.txt: No such file or directory",
)),
model_call: None,
})
.await?;
harness
.tx_event
.send(EngineEvent::TurnComplete {
usage: Usage::default(),
parent_route_usage: Usage::default(),
routed_usage_dropped_records: 0,
status: TurnOutcomeStatus::Completed,
error: None,
tool_catalog: None,
base_url: None,
})
.await?;
wait_for_terminal_turn(&manager, &turn.id).await?;
let detail = manager.get_thread_detail(&thread.id).await?;
let failed = detail
.items
.iter()
.find(|item| {
item.metadata
.as_ref()
.is_some_and(|meta| meta["tool_use_id"] == "host-read")
})
.context("failed call")?;
assert_eq!(failed.status, TurnItemLifecycleStatus::Failed);
let meta = failed.metadata.as_ref().unwrap();
assert_eq!(meta["tool_result_for"], "host-read");
assert_eq!(meta["is_error"], true);
assert_eq!(meta["provider_tool_use_id"], "call_00_missing");
// Restart on the same home: the next request is rebuilt from the
// persisted records alone.
drop(harness);
drop(manager);
let manager = test_manager(runtime)?;
let reopened = manager.get_thread(&thread.id).await?;
let messages = manager.restore_thread_messages(&reopened)?;
let blocks: Vec<&ContentBlock> = messages.iter().flat_map(|m| &m.content).collect();
let calls: Vec<&ContentBlock> = blocks
.iter()
.copied()
.filter(|block| matches!(block, ContentBlock::ToolUse { .. }))
.collect();
let results: Vec<&ContentBlock> = blocks
.iter()
.copied()
.filter(|block| matches!(block, ContentBlock::ToolResult { .. }))
.collect();
assert_eq!(calls.len(), 1, "the failed call is replayed");
assert_eq!(results.len(), 1, "and answered exactly once");
assert_eq!(calls[0].tool_call_key(), results[0].tool_call_key());
let ContentBlock::ToolResult {
tool_use_id,
content,
is_error,
..
} = results[0]
else {
unreachable!()
};
assert_eq!(tool_use_id, "call_00_missing");
assert_eq!(*is_error, Some(true));
assert!(content.contains("No such file or directory"), "{content}");
Ok(())
}
}
#[tokio::test]