Skip to content

runtime: persist tool-call identity so restarts replay valid history (#5823) - #5840

Merged
Hmbown merged 1 commit into
mainfrom
fix/serve-tool-history-5823
Sep 2, 2026
Merged

runtime: persist tool-call identity so restarts replay valid history (#5823)#5840
Hmbown merged 1 commit into
mainfrom
fix/serve-tool-history-5823

Conversation

@Hmbown

@Hmbown Hmbown commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Closes #5823.

Single-commit branch off current origin/main tip (66360de). Gates on the worktree: cargo fmt clean, check-versions OK (0.9.11 in sync; range advisory is pre-existing), dead-code budget PASS at 425. Clippy workspace + tui nextest to confirm via CI matrix.


Note

Medium Risk
Changes how tool turns are persisted and replayed into provider messages; mistakes could drop or mis-pair tool history on restart, but scope is localized to runtime thread reconstruction and event handling.

Overview
Fixes #5823: after a runtime restart, conversation history rebuilt from persisted turn items no longer emits empty tool_call shells that strict OpenAI-compatible APIs reject.

Persistence now records tool_use_id, tool_name, and tool_input in item metadata when a tool call starts (since detail is overwritten with output on completion). On completion, metadata merges those fields with tool_result_for and is_error instead of replacing them with output-only metadata.

History rebuild (reconstruct_messages_from_turns) treats call and result as separate signals on one or two items: it restores ToolUse only when id and name are present, ToolResult when tool_result_for is set, and skips legacy snapshots with no durable identity rather than replaying null arguments.

Regression tests cover completed calls, in-flight calls after restart, and legacy items without metadata.

Reviewed by Cursor Bugbot for commit 208ce71. Bugbot is set up for automated code reviews on this repo. Configure here.

…5823)

The live engine path persisted tool-call items without their identity:
ToolCallStarted kept id/name/input only in the transient item.started
event payload, and ToolCallComplete overwrote detail with the tool
output and metadata with tool-output metadata. After a serve --http
restart, reconstruct_messages_from_turns rebuilt assistant tool_calls
with empty id/name and arguments "null", which strict OpenAI-compatible
endpoints reject with 400 "missing field 'name'".

Persist tool_use_id/tool_name/tool_input on the item snapshot at
ToolCallStarted, carry them through ToolCallComplete, and mark the
terminal result (tool_result_for/is_error) so one merged item rebuilds
the paired tool_call/tool_result. Snapshots persisted before identity
was durable now contribute nothing to rebuilt history instead of an
empty tool_call shell.

Tests: 11912 passed, 13 skipped (codewhale-tui lib, nextest ci profile).

Fixes #5823
Copilot AI lite review requested due to automatic review settings September 2, 2026 20:07
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@cursor

cursor Bot commented Sep 2, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_6f8eb76e-fa2d-465e-8e05-e8a13fe9e095)

@claude

claude Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Hmbown's task in 3m 55s —— View job


Review: runtime tool-call identity persistence (#5823)

Summary: Solid, well-targeted fix. The root cause — metadata: None at ToolCallStarted meant restart-time history rebuild always replayed an empty-id/name tool_call shell — is correctly diagnosed and fixed by persisting tool_use_id/tool_name/tool_input in the item snapshot at start and merging them forward at completion. The read side (reconstruct_messages_from_turns) correctly handles both the "single item carries both call+result" shape (live completed turns) and the "two items" shape (seeded/imported history), and correctly sources input from metadata.tool_input rather than the now-overwritten detail field. Nice touch verifying downstream: client.rs::prepare_model_bound_request already runs tool_history_repair::repair_tool_call_pairs_for_provider, which is why the tested in-flight/interrupted case (restart_rebuild_keeps_in_flight_tool_call_identity) is safe even though it leaves an unpaired ToolUse — that's an existing safety net, correctly relied on rather than reimplemented (ponytail rung 2).

Finding (see inline comment on crates/tui/src/runtime_threads.rs:8796-8813): The Ok branch merges started identity + tool_result_for/is_error into item.metadata, but the sibling Err(err) => branch (lines 8816-8821, just outside this diff) does not. A genuinely-failed tool call (engine returned Err, not a structured ToolOutput{success:false}) keeps only the started identity with no tool_result_for. After restart this replays as an unpaired ToolUse; tool_history_repair catches it before the wire but overwrites the real captured error (item.detail = err.to_string()) with a generic "process exit" placeholder and logs it as a crash repair, which is misleading for what was actually a normal completed-with-error turn. Suggested fix included inline (mirror the Ok branch's merge, is_error: true).

Pre-existing, same-topic gap worth a follow-up (not in this diff): the REQUEST_USER_INPUT_TOOL_NAME completion branch (crates/tui/src/runtime_threads.rs:8770-8783) fully overwrites item.metadata with {"tool_call_id": ..., "response_redacted": true} — a different key (tool_call_id vs tool_use_id) and no tool_result_for. On restart this call+response is silently dropped from replayed history entirely (falls into the "legacy skip" path). Given this PR's stated goal is restart history fidelity, this tool remains an unaddressed case of the same class — likely worth a follow-up ticket rather than blocking this PR.

Tests: The three new tests cover the important shapes well — completed paired call, in-flight/interrupted call, and legacy no-metadata skip — and the interrupted-call test's docstring correctly acknowledges reliance on tool_history_repair downstream. No test covers the Err(err) engine-failure path noted above.

Provider/model/route safety: N/A — this diff doesn't touch provider selection, model routing, or prefix inference.

Reuse/simplification: Good — no new abstraction, reuses existing ContentBlock/TurnItemRecord shapes and the existing tool_history_repair safety net rather than duplicating it.

Comment on lines +8796 to +8813
let mut meta = match output.metadata {
Some(Value::Object(map)) => Value::Object(map),
_ => json!({}),
};
if let Some(obj) = meta.as_object_mut() {
if let Some(started) =
item.metadata.as_ref().and_then(Value::as_object)
{
for key in ["tool_use_id", "tool_name", "tool_input"] {
if let Some(value) = started.get(key) {
obj.insert(key.to_string(), value.clone());
}
}
}
obj.insert("tool_result_for".to_string(), json!(id));
obj.insert("is_error".to_string(), json!(!output.success));
}
item.metadata = Some(meta);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good fix for the Ok branch, but note the Err(err) => branch a few lines below (crates/tui/src/runtime_threads.rs:8816-8821, outside this diff) has no equivalent: it never sets tool_result_for/is_error, so it keeps only the tool_use_id/tool_name/tool_input set at ToolCallStarted.

After a restart, reconstruct_messages_from_turns will replay that item as a ToolUse with no paired ToolResult. tool_history_repair::repair_tool_call_pairs_for_provider catches this before it hits the wire, but it synthesizes a generic "Tool call interrupted by process exit; terminal status: crashed_and_repaired." placeholder — discarding the real error text already captured in item.detail = Some(err.to_string()) and mislabeling a normal tool-execution failure as a process-crash repair.

Worth mirroring this same merge (tool_result_for: id, is_error: true) in the Err branch so the actual error survives replay.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The changes are localized, align with the reported root cause, and include targeted regression tests covering the key restart/replay scenarios.

Pull request overview

Fixes runtime thread restart behavior by persisting tool-call identity (id/name/input) in durable turn-item metadata, allowing history reconstruction to replay valid tool_call/tool_result messages for strict OpenAI-compatible providers (regression #5823).

Changes:

  • Persist tool_use_id, tool_name, and serialized tool_input in TurnItemRecord.metadata at ToolCallStarted.
  • On ToolCallComplete, merge result metadata with the persisted identity and add tool_result_for/is_error markers (instead of overwriting metadata with output-only data).
  • Update reconstruct_messages_from_turns to rebuild ToolUse/ToolResult blocks from metadata and skip legacy tool snapshots lacking durable identity; add regression tests covering completed, in-flight, and legacy cases.
File summaries
File Description
crates/tui/src/runtime_threads.rs Persists tool-call identity in item metadata and reconstructs tool blocks from durable metadata on restart.
crates/tui/src/runtime_threads/tests.rs Adds restart regression tests ensuring tool-call identity is preserved/replayed and legacy snapshots are safely skipped.
Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@codewhale-agent codewhale-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codewhale review

The PR persists tool-call identity at ToolCallStarted and merges it with result markers at ToolCallComplete so restart history rebuild can emit paired, non-empty tool_call/tool_result blocks. The reconstruction branch and regression tests cover the main completed-call and legacy-skip cases, but there are edge-case risks around parallel tool calls, non-object output metadata, and legacy in-progress items.

Findings

  • [WARNING] Completed live tool-call items can split parallel tool calls into separate assistant/tool turns (crates/tui/src/runtime_threads.rs:8357)
    Each completed live tool-call item now carries both call identity and tool_result_for, and the result branch flushes assistant_blocks immediately after each item. If one assistant turn issued multiple parallel tool calls, reconstruction will produce assistant(tool A) -> user(result A) -> assistant(tool B) -> user(result B) instead of a single assistant message containing all tool calls followed by their results. This changes the replayed history semantics and may be rejected by strict providers.
  • [WARNING] Completion merge discards non-object output metadata (crates/tui/src/runtime_threads.rs:8794)
    The new match output.metadata { Some(Value::Object(map)) => Value::Object(map), _ => json!({}) } resets any non-object metadata to an empty object. The previous code assigned output.metadata.clone() directly, preserving arbitrary serde_json::Value metadata. If a tool output can carry string/array/null metadata, that data is now lost at completion and may affect consumers of item.metadata.
  • [WARNING] Legacy in-progress item can still become a standalone ToolResult after upgrade (crates/tui/src/runtime_threads.rs:8808)
    For an item persisted before tool identity existed (item.metadata is None), completion still inserts tool_result_for and is_error. Reconstruction then sees a non-empty tool_result_for but empty tool_use_id/tool_name, so it emits a ToolResult with no preceding ToolUse. The PR explicitly plans to skip legacy items without durable identity, so the completion merge should avoid adding tool_result_for unless the start identity was copied.
  • [INFO] No test covers the actual ToolCallComplete metadata merge path (crates/tui/src/runtime_threads/tests.rs)
    The new regression tests construct TurnItemRecord values by hand and only exercise reconstruct_messages_from_turns. The completion branch that merges output.metadata with start-time tool identity and writes tool_result_for/is_error is not driven through the engine, so a bug in that merge (such as missing key copy, wrong id, or the non-object metadata handling) would not be caught.

Assessment

The core fix addresses #5823 for the common completed-call path, and the reconstruction tests are a solid start. Before merge, the parallel-tool-call grouping risk, non-object metadata preservation, and legacy in-progress completion behavior should be reviewed and preferably covered by tests.


Advisory review by Codewhale (codewhale review --pr 5840 --post, head 208ce7182d608244dc3b8cfc14d1ada858719124). Line-specific findings are also posted as inline review comments; mechanical fixes arrive as committable suggestions you can apply from the Files tab. CODEOWNERS approval still governs merge.

});
}
if !tool_result_for.is_empty() {
flush_assistant(&mut assistant_blocks, &mut messages);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] Completed live tool-call items can split parallel tool calls into separate assistant/tool turns

Each completed live tool-call item now carries both call identity and tool_result_for, and the result branch flushes assistant_blocks immediately after each item. If one assistant turn issued multiple parallel tool calls, reconstruction will produce assistant(tool A) -> user(result A) -> assistant(tool B) -> user(result B) instead of a single assistant message containing all tool calls followed by their results. This changes the replayed history semantics and may be rejected by strict providers.

// call identity persisted at start must be
// carried through metadata. Mark the
// terminal result too so restart history
// rebuild can re-emit the paired

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] Completion merge discards non-object output metadata

The new match output.metadata { Some(Value::Object(map)) => Value::Object(map), _ => json!({}) } resets any non-object metadata to an empty object. The previous code assigned output.metadata.clone() directly, preserving arbitrary serde_json::Value metadata. If a tool output can carry string/array/null metadata, that data is now lost at completion and may affect consumers of item.metadata.

if let Some(value) = started.get(key) {
obj.insert(key.to_string(), value.clone());
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] Legacy in-progress item can still become a standalone ToolResult after upgrade

For an item persisted before tool identity existed (item.metadata is None), completion still inserts tool_result_for and is_error. Reconstruction then sees a non-empty tool_result_for but empty tool_use_id/tool_name, so it emits a ToolResult with no preceding ToolUse. The PR explicitly plans to skip legacy items without durable identity, so the completion merge should avoid adding tool_result_for unless the start identity was copied.

@Hmbown
Hmbown merged commit 7ea8eba into main Sep 2, 2026
49 of 50 checks passed
@Hmbown
Hmbown deleted the fix/serve-tool-history-5823 branch September 2, 2026 21:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

serve --http: threads with tool-call history fail with 400 missing field name after a runtime restart

2 participants