Skip to content

fix(tui): a fork continues when a turn lost its tool call - #6664

Open
gaord wants to merge 2 commits into
Hmbown:mainfrom
gaord:fix/fork-rebuild-lost-tool-call
Open

gaord wants to merge 2 commits into
Hmbown:mainfrom
gaord:fix/fork-rebuild-lost-tool-call

Conversation

@gaord

@gaord gaord commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Continuing a forked conversation could fail on the first message with
400 No tool output found for tool call …, and retrying the fork answered
cannot identify an exact saved-history boundary. Both come from the same
place, found while using the fork capability from #6580 on a compacted
conversation.

A turn whose tool call never produced a result — a failed tool, or a process
that died mid-call — is persisted as one tool_call item that carries the call
and its failure text, with no tool_result_for item. A history rebuilt from
the turn records (what a fork of a compacted source does) therefore emitted the
call alone, and two things followed:

  • the response after the lost call was glued into the same assistant message
    (thinking, call, thinking, call), because the missing result is what
    failed to flush the first response — the live transcript held those as two
    separate messages; and
  • the request-time repair answered the call by inserting a result next to that
    merged message, which the Responses API reads as the first call never being
    answered, so every turn the fork ran failed the same way.

The recovery projection had a second, independent problem: a fork inherits the
source's compaction summary as its system prompt, so on its first load the
engine installs that checkpoint message into the fork's own document. No turn
record reproduces it, so the document read as drifted from the records it was
rebuilt from, and the next cut — /undo, retry, fork-again on the fork itself
— refused.

What changed

  • reconstruct_messages_from_turns_with answers a call whose item records no
    outcome, where the live transcript held the answer: the item's own failure
    text for a failure, and the repository's interrupted-call notice for a call
    the process never finished (CRASH_REPAIR_CONTENT, now the one spelling the
    session repair and a rebuild share). An answer is paired only with a call the
    rebuild actually emits — a snapshot with no tool name is skipped (serve --http: threads with tool-call history fail with 400 missing field name after a runtime restart #5823), so
    the identity rule is checked here too — and a call the turn already answers
    with its own tool_result_for item is left alone. A call still running is
    deliberately untouched: the engine is waiting for its result.
  • The recovery projection reads an engine-owned compaction checkpoint as the
    engine's own message rather than as a prompt no record holds, so a document
    carrying one is still cut at the turn the caller names.
  • SUMMARY_HEADER becomes crate-visible so the projection's recognition rule
    and its tests share one literal.

No new layer; the existing tool_history_repair module keeps ownership of the
interrupted-call notice.

Testing

Run against the commit this branch was rebased from (d49fb9d99); the branch
is based on 2d9613bac, whose changes to these files are in unrelated regions
(sub-agent display names, custom-thread route tests), so CI covers the rebased
tree.

  • cargo fmt --all -- --check — clean.
  • cargo clippy -p codewhale-tui -p codewhale-runtime --all-targets -- -D warnings -A clippy::uninlined_format_args -A clippy::too_many_arguments -A clippy::unnecessary_map_or -A clippy::collapsible_if -A clippy::assertions_on_constants — clean.
  • cargo test -p codewhale-tui --lib runtime_threads — 240 passed; 1 failed; 2 ignored. The failure is host_goal_loop_kickoff_arms_one_continuation_and_parks_at_engine_cap, a load-sensitive goal-continuation test: it fails the same way on the unmodified checkout under the same parallel load and passes when run alone. Not from this change.
  • cargo test -p codewhale-tui --lib compaction — 154 passed; 0 failed.
  • cargo test -p codewhale-tui --lib session_manager — 107 passed; 0 failed; 1 ignored.
  • cargo test -p codewhale-runtime tool_history_repair — 8 passed; 0 failed.

All three new tests were shown failing with the two behaviours disabled, and
passing with them:

  • a_rebuilt_turn_answers_a_tool_call_whose_outcome_the_store_lost — a turn
    whose first response lost its call is rebuilt as two responses, each with its
    own call and its own answer. Without the fix the fork's document came out as
    ("assistant", ["thinking", "tool_use", "thinking", "tool_use"]) plus a
    repair notice, and the calls were left unanswered. The second half retries the
    fork itself, over a document carrying the engine's checkpoint.
  • a_compaction_checkpoint_is_not_a_kept_prompt — without the fix the prompt
    walk returns None (the refusal) on a transcript that carries a checkpoint;
    with it, the boundary is found.
  • a_lost_call_is_answered_only_where_its_own_outcome_is_known — the failure
    text, the interrupted-call notice and every status this must leave alone.

Checklist

  • This PR adds a new layer/module/abstraction — it names or deletes the layer it replaces
    → no new layer; the existing repair module keeps the notice's ownership.
  • Updated docs or comments as needed
  • Added or updated tests where relevant
  • Verified TUI behavior manually if UI changes → no UI change (engine-side history).
  • Harvested/co-authored credit uses a GitHub numeric noreply address

CHANGELOG.md and crates/tui/CHANGELOG.md are deliberately untouched
(CONTRIBUTING: entries are written on main at merge time).

What this does not address

Documents an older build already wrote keep the merged assistant message, so
cutting one forward (undo/retry/fork on that thread) hands the shape back. A
fork built from the turn records — the path a compacted source takes — is clean
with this change, so branching again from the conversation produces a healthy
history. Healing an existing document would mean splitting a merged assistant
message (and its results) in the history-repair layer, which is a wider change
than this fix; left out deliberately.

Forking a compacted conversation and sending the next message could fail on
that first message with `400 No tool output found for tool call …`, and
retrying the fork answered `cannot identify an exact saved-history boundary`.
Both start in a turn whose tool call never produced a result — a failed tool,
or a process that died mid-call. That call is persisted as a `tool_call` item
carrying the call and its failure text, with no `tool_result_for` item, so a
history rebuilt from the records emitted the call alone: the response that
followed was glued into the same assistant message (no result had flushed it),
and the repair answering the call put its answer next to a message that now
held two responses — a shape the Responses API reads as the earlier call never
being answered. The messages that repair wrote also stopped the fork's
document from matching its records, so `/undo`, retry and fork-again refused.

A rebuilt turn now answers that call where the live transcript held its
answer: the item's own failure text for a failure, and the repository's
interrupted-call notice for a call the process never finished — the same
spelling the session repair writes. An answer is paired only with a call the
rebuild actually emits (a snapshot with no tool name is skipped, Hmbown#5823), and a
call the turn already answers elsewhere is left alone. A call still running is
deliberately untouched: the engine is waiting for its result.

The recovery projection also reads the compaction checkpoint the engine
installs into a synced history as the engine's own message rather than a
prompt no turn record holds. A fork of a compacted source inherits the summary
in its system prompt, so the engine wrote that checkpoint into the fork's own
document on its first load, and the prompt walk then refused the cut.

Tests: cargo test -p codewhale-tui --lib runtime_threads — 240 passed, 1
failed (host_goal_loop_kickoff_arms_one_continuation_and_parks_at_engine_cap,
a load-sensitive goal-continuation test that fails the same way on the
unmodified checkout and passes alone). All three new tests fail without the
fix: a_rebuilt_turn_answers_a_tool_call_whose_outcome_the_store_lost (the
fork's document came out as assistant[thinking, tool_use, thinking, tool_use]
plus a repair receipt), a_compaction_checkpoint_is_not_a_kept_prompt, and
a_lost_call_is_answered_only_where_its_own_outcome_is_known. compaction — 154
passed; session_manager — 107 passed; codewhale-runtime tool_history_repair —
8 passed; rustfmt clean; clippy clean for the touched crates under the CI
allow list.
@gaord
gaord requested a review from Hmbown as a code owner September 26, 2026 23:58
Hmbown pushed a commit that referenced this pull request Sep 27, 2026
Contributor PR by @gaord (Ben Gao); their commit 9ddf26b is kept as-is
with its original authorship.

crates/tui/src/compaction.rs: the already-merged compaction handoff work
replaced the Codex-derived SUMMARY_HEADER ("Another language model
started...") with the "Codewhale handoff note" header plus HANDOFF_SECTIONS;
#6664 only made the old SUMMARY_HEADER pub(crate) so the runtime_threads
recovery-projection tests can build a checkpoint message. Kept the new
handoff header text and sections, and made that SUMMARY_HEADER pub(crate)
with #6664's reason. #6664's test builds the checkpoint from
SUMMARY_HEADER, which still starts with COMPACTION_SUMMARY_MARKER, so it
remains a structural (wire) checkpoint.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

2 participants