Skip to content

fix(desktop): branch no longer drops the conversation tail - #98025

Open
hutao562 wants to merge 2 commits into
NousResearch:mainfrom
hutao562:fix/desktop-branch-row-id
Open

hutao562 wants to merge 2 commits into
NousResearch:mainfrom
hutao562:fix/desktop-branch-row-id

Conversation

@hutao562

@hutao562 hutao562 commented Aug 29, 2026 •

Copy link
Copy Markdown

Summary

Fixes the desktop "Branch in chat" cutting the parent conversation at the wrong place — dropping the conversation tail on whole-chat forks and mis-slicing message-level forks (#80973, partially #87949).

Root cause. session.branch truncated the parent's live history with history[:count], where count came from the desktop's toBranchMessages() over the merged toChatMessages projection (tool rows folded into assistant bubbles, empty tool-call turns collapsed, timeline markers interleaved). The merged-bubble count has no stable mapping onto the backend's raw row history, so the slice landed wherever the two count spaces happened to disagree — a 261-row parent forked only its first ~108 rows.

Two more defects a pure count → row-id swap would not fix:

  1. Merged bubbles span several rows. An auto-continued long answer is persisted as multiple assistant rows plus folded tool rows, but the bubble only carried the first row's rowId. Cutting at that id would still drop the bubble's own tail. Bubbles now carry endRowId — the last durable row of the span they cover — and the cut uses it.
  2. Nested branches addressed the wrong database. A branch child starts with its parent's ChatMessage objects (parent row ids) and its live history kept the parent's _row_ids after the copy. A second-generation fork then sent an id that exists in the child's REST transcript but not in its live history. The handler now re-addresses the copied history with the child's new SQLite ids (read-back with include_row_ids), and the child's initial renderer state prefers the transcript returned by session.branch.

Rebased on main (2026-09-03). The blank-window-after-branch half of the original PR is fixed on main (30252ecc "navigate to branched session") and has been dropped from this branch. The mainline use-session-actions changes (branch-create flight cache, connection-aware requestBranchGateway router, branchCount/ ownerRoute params) predate this diff; the row-id flow now rides the new parameter slot and the legacy count slot stays permanently empty. tsc --noEmit clean; use-session-actions suite 246 passed.

Changes

Backend — session.branch (tui_gateway/methods_session.py)

  • Whole raw history is copied by default; truncation happens only via up_to_row_id (durable messages.id).
  • The cut is ordinal ("keep rows whose id ≤ target"), not an exact-member match: a merged bubble's terminal id may belong to a row this projection filtered out (folded tool row, hidden marker), and requiring that exact row would wrongly reject the fork. Ids are monotonically increasing in insertion order, so the ordinal cut lands exactly on the last visible row of the clicked bubble's span.
  • Id-range guard: a target outside [min, max] of the ids this transcript ever persisted is refused with 4009 branch target row not found — a stale/foreign id can never silently broaden the fork into a full-history branch (the exact failure mode the count-based path had).
  • After the copy, the in-memory history is re-stamped with the child's new row ids, so nested branches resolve against the child's own database.
  • The legacy count parameter is still honored, so an older desktop against this gateway behaves no worse than today.

Frontend (desktop)

  • toChatMessages hydration stamps endRowId on every bubble: continuation rows merged into an active assistant, tool results folded via applyStoredToolResult, and pending tool flushes. rowId keeps naming the first row (reactions still address it).
  • branchCurrentSession addresses the cut with the terminal bubble's endRowId ?? rowId; a fresh turn that has not round-tripped row ids falls back to resolving against the REST transcript (resolveDurableRowIdForMessage, same-role/text ordinal matching), and if the cut still cannot be addressed the fork is refused with an error instead of silently retargeting.
  • Whole-chat forks send no truncation at all; the backend copies everything.

Test plan

  • tsc --noEmit clean
  • vitest: use-session-actions suite — 246 passed (includes mainline's new connection-routing tests + this PR's merged-count/row-id/refusal tests)
  • Backend tests/test_tui_gateway_server.py covers the ordinal cut, id-range guard, and nested-branch re-addressing

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/desktop Electron desktop app (apps/desktop/*) comp/tui Terminal UI (ui-tui/ + tui_gateway/) area/sessions Session lifecycle, resume, persistence, history sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Aug 29, 2026
@hutao562
hutao562 force-pushed the fix/desktop-branch-row-id branch 2 times, most recently from ba11b4b to 637d5d4 Compare August 29, 2026 17:51
@hutao562

hutao562 commented Sep 1, 2026

Copy link
Copy Markdown
Author

@OutThisLife friendly ping for a review when you have a moment — this picked up the type/bug + P2 labels in triage but has been quiet since it opened, and the CI checks remain skipped.

What it fixes: the desktop "Branch in new chat" context-loss bug. session.branch truncated the parent's live history with a count derived from the desktop's merged-bubble projection (toChatMessages), which has no stable mapping onto the backend's raw rows — a 261-row parent forked only its first ~108 rows, silently dropping the conversation tail.

Scope vs #95992: that PR fixed the sibling route/selection ordering issue; this one is complementary — it replaces count-based truncation with durable row_id addressing in the branch protocol, and additionally handles merged bubbles spanning multiple rows and collapsed empty tool-call turns (defects a pure count→row-id swap would not fix).

  • 10 files, +456/−32, desktop + gateway protocol aligned
  • Two new regression tests: whole-chat forks send no truncation param; message-level cuts address the durable DB row id

Happy to rebase if main has moved, or adjust anything.

kingOfSoySauce and others added 2 commits September 20, 2026 20:30
session.branch truncated the parent's live history with history[:count], where
count came from the desktop's toBranchMessages() over the MERGED toChatMessages
projection (tool rows folded into assistant bubbles, empty tool-call turns
collapsed, timeline markers interleaved). That merged-bubble count has no stable
mapping onto the backend's raw row history, so every desktop branch silently
sliced off the conversation tail — a 261-row parent forked only its first ~108
rows (NousResearch#80973, partially NousResearch#87949).

Two more defects a pure count -> row-id swap would not fix:

1. Merged bubbles span several rows. An auto-continued long answer is persisted
   as multiple assistant rows plus folded tool rows, but the bubble only carried
   the FIRST row's rowId. Cutting at that id would still drop the bubble's own
   tail, so bubbles now carry endRowId — the last durable row of the span they
   cover — and the cut uses it.
2. Nested branches addressed the wrong database. A branch child starts with its
   parent's ChatMessage objects (parent row ids) and its live history kept the
   parent's _row_ids after the copy. A second-generation fork then sent an id
   that exists in the child's REST transcript but not in its live history. The
   copy now re-stamps the in-memory history with the child's new SQLite ids
   (read back with include_row_ids).

Backend — session.branch (tui_gateway/methods_session.py)
- Whole raw history is copied by default; truncation happens only via
  up_to_row_id (durable messages.id).
- The cut is ORDINAL ("keep rows whose id <= target"), not an exact-member
  match: a merged bubble's terminal id may belong to a row this projection
  filtered out (folded tool row, hidden marker), and requiring that exact row
  would wrongly reject the fork. Ids are monotonically increasing in insertion
  order, so the ordinal cut lands exactly on the last visible row of the
  clicked bubble's span.
- Id-range guard: a target outside [min, max] of the ids this transcript ever
  persisted is refused with 4009 branch target row not found — a stale/foreign
  id can never silently broaden the fork into a full-history branch (the exact
  failure mode the count-based path had).
- After the copy, the in-memory history is re-stamped with the child's new row
  ids so nested branches resolve against the child's own database.

Frontend (desktop)
- toChatMessages hydration stamps endRowId on every bubble: continuation rows
  merged into an active assistant, tool results folded via applyStoredToolResult,
  and pending tool flushes. rowId keeps naming the first row (reactions still
  address it).
- branchCurrentSession addresses the cut with the terminal bubble's
  endRowId ?? rowId; a fresh turn that has not round-tripped row ids falls back
  to resolving against the REST transcript (resolveDurableRowIdForMessage,
  same-role/text ordinal matching), and if the cut still cannot be addressed the
  fork is refused with an error instead of silently retargeting.
- Whole-chat forks send NO truncation at all; the backend copies everything.
- The cut address is part of the branch create-flight identity, so two branches
  taken at different messages of the same live parent are not coalesced.

Contract
- session.branch params gain up_to_row_id; the legacy merged-message count is
  kept in the schema for older senders but IGNORED by the handler.
- DESKTOP_BACKEND_CONTRACT / REQUIRED_BACKEND_CONTRACT -> v8 (a v7 backend
  rejects the new parameter, so the desktop warns to align rather than failing
  cryptically), with the generated contract artefacts regenerated.

Test plan
- scripts/run_tests.sh tests/tui_gateway/test_tui_gateway_server.py — 669 passed
  (covers default full copy, ordinal cut, id-range refusal, nested-branch
  re-addressing)
- vitest use-session-actions + lib/chat-messages + store/updates — 421 passed
- tsc --noEmit clean

Rebased onto current main (2026-09-20) by @hutao562: ported to the split
tui_gateway modules, added the RPC parameter to the gateway contract + bumped
the GUI/backend contract to v8, regenerated the contract artefacts, folded the
cut address into the create-flight identity, and updated the regression tests.
@hutao562
hutao562 force-pushed the fix/desktop-branch-row-id branch from 11efcaa to 042fd47 Compare September 20, 2026 12:31
@hutao562

Copy link
Copy Markdown
Author

Rebased onto current main (501d8ba4) and ported to the refactored tui_gateway modules — the branch had gone conflicting while it sat (~11k commits behind).

Changes vs. the original revision, worth re-reading before review:

  • session.branch now declares up_to_row_id in the RPC contract (tui_gateway/contracts/sessions.py); the legacy merged-message count stays in the schema for older senders but is ignored by the handler — it was the whole bug.
  • DESKTOP_BACKEND_CONTRACT / REQUIRED_BACKEND_CONTRACT → v8, with apps/shared/src/gateway-contract.* regenerated. A v7 backend rejects the new parameter outright, so the desktop raises its "update to align" prompt instead of failing cryptically.
  • The cut address is now part of the branch create-flight identity, so two branches taken at different messages of the same live parent are no longer coalesced onto one create.
  • Design note: the cut is ordinal (keep rows with id <= target), not an exact-member match — a merged bubble's terminal id may name a row the display projection filtered out. An id outside the transcript's [min, max] is refused with 4009 branch target row not found rather than silently broadening into a full-history fork, which was the original failure mode.

Verification on the rebased tree:

  • scripts/run_tests.sh tests/tui_gateway/test_tui_gateway_server.py → 669 passed (default full copy, ordinal cut, id-range refusal, nested-branch row-id re-addressing)
  • vitest on use-session-actions + lib/chat-messages + store/updates → 421 passed
  • tsc --noEmit clean, ruff clean, contracts/test_generated.py green

session.branch still truncates by a merged-bubble count on main today, so this is not stale. Happy to drop the contract bump and keep count as a fallback if that is preferred for landing. @OutThisLife a review whenever you have a moment.

@tripflex

Copy link
Copy Markdown

Heads up: this fix is what resolves the branch-wrong-point bug I filed about on #80973 (and #77375, #70317). Curious if there's anything blocking it.

@tripflex

Copy link
Copy Markdown

I can confirm that this fixes the issue for me when I patch this into latest main

@tripflex

Copy link
Copy Markdown

@hutao562 heads up — when I pulled this onto latest main and rebased, I found that main has since removed endRowId (the ChatMessage.endRowId field this PR's desktop half relies on) and replaced it with serverRowSpan (3f985c84cd "count released transcript rows in backend rows"). They're not equivalent: serverRowSpan is a count of backend rows a folded bubble covers (used by the transcript-tail older-page offset), whereas the PR's endRowId is the terminal durable row id the branch cut needs (up_to_row_id = endRowId ?? rowId). The two conflict in types.ts, hydration.ts, and tool-parts.ts because they write the same absorption/fold code paths.

In my fork I kept both fields so the PR's branch addressing and main's transcript-tail accounting coexist (the terminal id and the row count are complementary, not duplicates). Worth folding that back into this PR so it merges cleanly against current main — as written it no longer applies without conflicts. Happy to share the reconciliation if useful.

@kvnloo

kvnloo commented Sep 23, 2026

Copy link
Copy Markdown

@teknium1 could you take a quick look at #98025 when you get a chance? this is still reproducible on current main, and @tripflex independently confirmed the patch fixes it in real use.

the underlying invariant is pretty small: the desktop currently chooses a branch boundary in merged-message space, while the backend truncates raw persisted rows. #98025 makes that boundary a durable row id instead, with guards against stale/foreign ids and coverage for nested branches.

we’ve also consolidated the older attempts around this bug so this should be the one review surface. if the protocol shape is the blocker, happy to adjust it — mainly looking for a yes/no on the approach.

@kvnloo

kvnloo commented Sep 23, 2026

Copy link
Copy Markdown

did another adversarial pass on this before review. the row-id direction still looks right, but i found a few edge cases worth tightening:

  1. the current "[min(row_id), max(row_id)]" guard doesn't prove the cut belongs to this session. sqlite ids are global, so a missing id inside that range could belong to another session. the ordinal cut is useful for folded rows, but ideally we should validate the boundary against the authoritative parent transcript/span rather than numeric range alone.

  2. when truncating, rows without "_row_id" are currently retained unconditionally. if there's an unaddressed live tail after the requested cut, that can leak later context into the branch. i think this should fail closed whenever we can't deterministically partition the raw history around the requested boundary.

  3. the "up_to_row_id=3" regression test currently proves ordinal slicing across visible ids "[1,2,4,5]", but doesn't prove that "3" was actually a folded row belonging to this parent. it'd be stronger to construct that case from the persisted parent transcript so a foreign id inside the numeric range is separately rejected.

  4. "_persist_branch()" treats failure to restamp the child's new row ids as non-fatal. now that nested branches depend on those ids being authoritative, i'm not sure that's a safe degradation — it can create a child that succeeds now but becomes unaddressable on the next branch.

the invariant i'd aim for is:

a branch boundary is a durable position in the parent's persisted transcript. if we can't prove that position, refuse the branch rather than approximating it from merged counts, numeric ranges, or partially-addressed live history.

also +1 to @tripflex's "serverRowSpan" / "endRowId" reconciliation — those represent different things (cardinality vs durable terminal identity), so keeping both seems cleaner than trying to derive one from the other.

i'd add adversarial coverage for: foreign id inside the parent's min/max, unaddressed live tail after the cut, a real folded tool/continuation span, and child → grandchild branching after persistence/reload.

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

area/sessions Session lifecycle, resume, persistence, history comp/desktop Electron desktop app (apps/desktop/*) comp/tui Terminal UI (ui-tui/ + tui_gateway/) P2 Medium — degraded but workaround exists sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants