Repository navigation
feat(runtime-api): fork a thread at a named turn - #6580
Merged
Merged
Conversation
gaord
force-pushed
the
feat/fork-at-turn
branch
2 times, most recently
from
September 26, 2026 03:41
c6804ea to
8193c59
Compare
gaord
marked this pull request as draft
September 26, 2026 03:59
POST /v1/threads/{id}/fork-at-turn names the user turn a client is looking at
and keeps it and everything before it, dropping the turns after it. Naming the
last turn keeps the whole conversation — the same rule read at the end of the
thread — and the receipt carries the first dropped turn's prompt, which is
what a client puts back in the composer to continue from there.
The anchor is a turn id, not a distance from the tail: the transcript a client
renders and the turn store this cuts are not the same list (steers, image-only
prompts and injected handoffs each sit on one side only), so a depth the
client computed is an off-by-one waiting to fork the wrong prefix while
reporting success.
No file rollback: the branch left behind shares the workspace, so `fork` stays
a sibling conversation and only `/undo` and `/patch-undo` touch files. The
suffix-cloning path is shared with the depth-relative cut
(`prepare_fork_from_cutoff`), which is where the two anchors could otherwise
drift apart in the saved-session alignment — that alignment names the first
dropped turn for either cut.
Verified on 8a835d7, the saved-session alignment this rebases onto: cargo
test -p codewhale-tui --lib fork_ 30 passed / undo 19 passed / retry 74 passed
/ saved_history 2 passed / boundary 71 passed, rustfmt clean, and the endpoint
driven end-to-end against a runtime built from this branch (201, keeps the
anchor turn, returns the next prompt, source unchanged, 400 for a non-user
turn, 404 for an unknown thread).
Branching a conversation while a turn is still in flight is the case this endpoint exists for — a person watching a turn go the wrong way should not have to stop it (and lose what it has already done) to take another path. The test seeds a thread whose newest turn is InProgress, forks at the finished turn before it, and pins both halves: the fork keeps the turn it names and hands back the question the running turn is answering, and the source thread — its record, the running turn's status and its items — is not touched. cargo test -p codewhale-tui --lib fork_ 31 passed (undo 19, retry 74).
Branching a thread — and the /undo, /retry and backtrack cuts that share its body — took about a minute on a long conversation, past the point where a client gives up on the request. Three costs added up: each turn's items were found by scanning the store's entire items directory, every cloned item was written with a full directory sweep and two syncs, and the saved transcript's alignment rebuilt a projection for every candidate prefix. A 45,000-item store with a 5 MB session went from 76.5s to 8.6s for a two-turn branch, and from 115.9s to 11.5s for the same cut behind undo; on a 49,000-item store a release build branches at an earlier turn in 9.9s where it took 92.2s. Items are read in one pass, published in one batch that still fsyncs every file and the directory once, and the alignment is found in one walk — the commit order (items, turns, then the thread) and the cleanup after a failed publish are unchanged. cargo test -p codewhale-tui --lib fork_/undo/retry/boundary/saved_session/ compaction/restore_ 418 passed, rustfmt clean, clippy clean under the CI allow list, and the fork endpoint end-to-end against a runtime built from this branch.
Once a conversation has been compacted, what is saved is the summary plus the prompts that stayed verbatim: an abbreviation of the turns rather than their transcript. A branch sliced that abbreviation and then claimed, through its checkpoint, to cover turns its own session document did not carry — so the branch opened as a list of questions, with the answers and tool calls that went with them missing, and resuming it had nothing left to rebuild them from. The turn records keep every item of every turn, so a branch of a compacted conversation now rebuilds the turns it keeps from them — the conversation the thread view shows — while an uncompacted source keeps copying its saved transcript verbatim, exactly as before. Reproduced on a real 15-turn conversation that had been compacted: the branch's session document went from 12 messages, every one of them a prompt, to 1004 messages (503 user, 501 assistant) carrying 501 tool calls, with the source thread, its document and the workspace untouched. The regression test fails on the pre-fix code, where the document holds the prompts alone. cargo test -p codewhale-tui --lib fork_/undo/retry/boundary/saved_session/ compaction/restore_ 418 passed, rustfmt clean, clippy clean under the CI allow list, and the fork endpoint end-to-end against a runtime built from this branch (13/13 checks).
gaord
force-pushed
the
feat/fork-at-turn
branch
from
September 26, 2026 05:43
8193c59 to
2b41040
Compare
…#6580 Adds the 0.10.1 Unreleased "Added" entry for POST /v1/threads/{id}/fork-at-turn, adds codewhale-hq#6580 to @gaord's credit line in CHANGELOG.md and docs/CONTRIBUTORS.md, and regenerates crates/tui/CHANGELOG.md and web/lib/changelog.generated.ts. @gaord is already in UNRELEASED_CONTRIBUTORS. Verification (docs-only change; no Rust touched): - scripts/sync-changelog.sh --check: up to date - scripts/release/check-versions.sh --range-audit-advisory: OK - scripts/check-contributor-credit.py v0.10.0: 5 contributors, all credited - web vitest lib/public-copy.test.ts: 6 passed / 0 failed - blocking-calls, dead-code, command-crate-boundaries budgets, module_graph --check, cargo fmt --check: pass Refs codewhale-hq#6580 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
gaord
marked this pull request as ready for review
September 26, 2026 05:50
…paction A named-turn fork cuts one past its anchor, and took the receipt (prompt, images, output allowance, dropped_turn_id) from the turn at the cut. A manual /compact is a turn of its own with no prompt, so branching at the turn before one returned no prompt and named the compaction as dropped; on a source whose saved history needed boundary alignment it refused the fork outright. The cut still drops everything after the anchor. The receipt and the saved-history boundary now come from the first dropped *user* turn; when no user turn is dropped, the whole saved transcript belongs to kept turns. The depth-relative /undo path passes its own user-turn index, unchanged. Tests: cargo test -p codewhale-tui --lib fork_ -> 33 passed, 0 failed (new: fork_at_user_turn_receipt_skips_a_compaction_turn_after_the_anchor); --lib backtrack -> 25 passed, 0 failed. cargo fmt --check clean; blocking calls, dead code, command crate boundary and module graph gates pass. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A saved session is repaired on the way out: `resume_session` re-pairs tool calls with their results, adds a notice for a call that never returned, and writes the repair back. A fork rebuilt its prefix from the turn records and fingerprinted that written shape, so its checkpoint named bytes no reader ever sees. Everything downstream then rejected the fork — restoring it refused the document, and resuming the session did not recognise the thread that held it (`thread_holding_session` compares exactly that fingerprint), so it opened a *second* thread for the same file. That second thread's save rebound only its own checkpoint, leaving the fork's stale, and the fork's next message failed with "Saved session … changed after this thread's checkpoint" — reported from the VS Code client after branching a compacted conversation. The checkpoint now fingerprints the document as it is read, which is also what the file holds once the fork has written it: the repair and the write agree. cargo test -p codewhale-tui --lib a_fork_fingerprints_the_document_its_readers_see fails on the pre-fix code (the fork's own thread is not recognised) and passes here; fork_/undo/retry/boundary/saved_session/compaction/restore_ 419 passed, rustfmt clean, clippy clean under the CI allow list, and the resume path driven end-to-end against a runtime built from this branch: one thread for the session, no stale checkpoint, and the next turn reaching the provider.
4 of 5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #6483 — the saved-history alignment this fork cut rebases onto. The endpoint has no tracking issue of its own; the client half lands separately in the CodeWhale VSCode extension.
Adds one endpoint:
POST /v1/threads/{id}/fork-at-turn({"turn_id": "turn_…"}) forks a thread at a named user turn. The fork keeps that turn and every turn before it and drops the turns after it; naming the last turn therefore keeps the whole conversation. The receipt is/undo's shape (thread,original_user_text,original_user_images) and carries the first dropped turn's prompt — what was asked next — so a client can put it back in the composer.Why a named turn instead of another
depth: a client cannot count these turns for itself. The transcript a client renders and the turn store this cuts are not the same list — steers, image-only prompts and injected handoffs each sit on one side only — so a depth the client computed is an off-by-one waiting to fork the wrong prefix while answering201. Naming the turn moves that decision to the side that owns the list. The enumeration itself (user_turn_indices) is now one function shared by both cuts.No file rollback: the branch left behind shares the workspace, so a fork stays a sibling conversation and only
/undoand/patch-undotouch files. The suffix-cloning path is shared with the depth-relative cut (prepare_fork_from_cutoff), which is where the two anchors could otherwise drift apart in the saved-history alignment added by #6483 — that alignment names the first dropped turn for either cut.Branching is also allowed while a turn is running, and does not interrupt it (the runtime owns the turn; a client that switches away only stops watching). Nothing here reads or writes the running turn, and the new test pins that the source record, the running turn's status and its items are untouched.
Tests (all on top of main @ 8a835d7):
cargo test -p codewhale-tui --lib fork_→ 31 passed / 0 failed (includes the two new endpoint tests: keep-the-anchor cut and returned prompt; whole-conversation cut with nothing to return; non-user turn → 400)cargo test -p codewhale-tui --lib undo→ 19 passed;--lib retry→ 74 passed;--lib saved_history→ 2 passed;--lib boundary→ 71 passedrustfmt --edition 2024 --checkclean on the four touched filesEnd to end against a runtime built from this branch (isolated
CODEWHALE_HOME, seeded from a real store, no model calls): GET-probe of the route answers 405 (what a client's capability probe needs); fork at an earlier turn keeps["first"]and returns the next prompt; fork at the last turn keeps both turns and returns nothing; the source thread is unchanged; a non-user turn is 400 and an unknown thread is 404.The client half (a per-turn "branch" row in the CodeWhale VSCode extension) lands separately; it is capability-gated on this route, so an older engine simply offers no row.