Skip to content

fix(session): truncate branch context_messages at fork prefix (#5096 Bug A) - #5124

Merged
1 commit merged into
nesquena:masterfrom
b3nw:fix/branch-truncate-context-messages
Jun 28, 2026
Merged

1 commit merged into
nesquena:masterfrom
b3nw:fix/branch-truncate-context-messages

Conversation

@claw-io

@claw-io claw-io commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Fix #5096 (Bug A): truncate branch context_messages at fork prefix

Refs #5096 (does not close the full bundle).

Thinking Path

  • Hermes WebUI lets users branch a session from an earlier turn; the branch should continue as if history ended at the fork point.
  • POST /api/session/branch sliced messages to the fork prefix but deep-copied the parent’s full context_messages, so the model on the next turn still saw post-fork context (fix(session): rewind/fork leaves stale model context (branch, truncate, edit index) #5096 Bug A).
  • Display transcript and model-facing context must stay aligned when forking, including sessions where context_messages is longer than messages (compaction-only leading rows).
  • This PR truncates context_messages to the same semantic prefix as the forked display messages via a shared helper.

What Changed

  • api/session_ops.py — truncate_context_for_display_keep(context_messages, full_messages, keep) aligns context length/shape with full_messages[:keep].
  • api/routes.py — branch handler sets context_messages from forked_context instead of copying the entire parent context.
  • tests/test_issue_branch_context_at_fork.py — unit tests for the helper (no parent-only tail after fork keep).

No CHANGELOG.md edits.

Why It Matters

Forking looked correct in the sidebar but the agent could answer using knowledge from turns the user had intentionally discarded. That breaks trust in branch-as-rewind and matches the failure mode described in #5096.

Verification

  • ./scripts/test.sh tests/test_issue_branch_context_at_fork.py — passed locally after rebase on upstream/master.
  • Manual (recommended): session with 3+ turns → fork at turn 2 → ask something only answerable if turn 3+ were in context; agent should not use removed tail.

Risks / Follow-ups

  • context_engine_state is still deep-copied from the parent (unchanged). If odd behavior persists after fork, trim engine state in a follow-up.
  • End-to-end HTTP branch test optional; maintainer asked for integration coverage — helper + route wiring are covered; full send-turn assertion can follow if needed.

Release note (for maintainers)

Fixed: Branching from a fork point no longer copies the parent’s full model context; context_messages are truncated to match the forked transcript prefix.

Model Used

  • Provider: custom (llm-proxy) / Hermes developer profile
  • Model: x-ai/grok-composer-2.5-fast
  • AI disclosure: AI assisted implementation, tests, and this PR description.

Co-authored-by: b3nw b3nw@users.noreply.github.com

Refs nesquena#5096

Co-authored-by: b3nw <b3nw@users.noreply.github.com>
@greptile-apps

greptile-apps Bot commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes Bug A from #5096: POST /api/session/branch was deep-copying the parent's full context_messages instead of truncating it to the fork prefix, so the model could answer using turns the user had intentionally discarded.

  • api/session_ops.py — new truncate_context_for_display_keep helper aligns context_messages length with full_messages[:keep], including a special path for compaction-only leading rows (where len(ctx) > len(msgs)).
  • api/routes.py — the branch handler now computes forked_context via the helper and deep-copies that instead of the full parent context; the change is minimal and correctly wired.
  • tests/test_issue_branch_context_at_fork.py — two unit tests cover the equal-length and compaction-prefix cases; the len(ctx) < len(msgs) fallback path remains untested.

Confidence Score: 3/5

The core branch-fork fix is correct for the common cases, but the helper has an untested fallback for shorter-context sessions that silently mistruncates from the wrong end.

The equal-length and compaction-prefix cases are handled and tested correctly. However, when len(context_messages) < len(messages), the function falls through to ctx[:keep] (front-aligned) rather than tail-aligned slicing, which contradicts the stated contract and could produce wrong context in the branch. Whether this code path is reachable is unclear and there is no guard.

api/session_ops.py — specifically the fallback branch when len(context_messages) < len(messages) and the unconditional preservation of compaction prefix rows on fork.

Important Files Changed

Filename Overview
api/session_ops.py Adds truncate_context_for_display_keep helper; handles the len(ctx)>len(msgs) compaction case and the equal-length case correctly, but the len(ctx)<len(msgs) fallback treats context as front-aligned without documentation or test coverage.
api/routes.py Branch handler correctly computes fork_keep, calls the new helper, and deep-copies the truncated context — fixing the core bug of copying the full parent context on fork.
tests/test_issue_branch_context_at_fork.py Two unit tests cover the equal-length case and the compaction-prefix case; missing newline at EOF, and no test for len(ctx)<len(msgs) or keep=0.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Client
    participant routes.py
    participant session_ops.py
    participant Session

    Client->>routes.py: "POST /api/session/branch {session_id, keep_count}"
    routes.py->>routes.py: Load source session, build source_messages
    routes.py->>routes.py: "fork_keep = keep_count ?? len(source_messages)"
    routes.py->>session_ops.py: truncate_context_for_display_keep(context_messages, source_messages, fork_keep)
    note over session_ops.py: len(ctx)==len(msgs) → ctx[:keep]<br/>len(ctx)>len(msgs) → prefix + suffix[:keep]<br/>else → ctx[:keep]
    session_ops.py-->>routes.py: forked_context (truncated list)
    routes.py->>Session: "new Session(messages=forked_messages, context_messages=deepcopy(forked_context))"
    routes.py-->>Client: "{session_id, title, parent_session_id}"
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Client
    participant routes.py
    participant session_ops.py
    participant Session

    Client->>routes.py: "POST /api/session/branch {session_id, keep_count}"
    routes.py->>routes.py: Load source session, build source_messages
    routes.py->>routes.py: "fork_keep = keep_count ?? len(source_messages)"
    routes.py->>session_ops.py: truncate_context_for_display_keep(context_messages, source_messages, fork_keep)
    note over session_ops.py: len(ctx)==len(msgs) → ctx[:keep]<br/>len(ctx)>len(msgs) → prefix + suffix[:keep]<br/>else → ctx[:keep]
    session_ops.py-->>routes.py: forked_context (truncated list)
    routes.py->>Session: "new Session(messages=forked_messages, context_messages=deepcopy(forked_context))"
    routes.py-->>Client: "{session_id, title, parent_session_id}"
Loading

Reviews (1): Last reviewed commit: "fix(session): truncate context_messages ..." | Re-trigger Greptile

Comment thread api/session_ops.py
Comment on lines +82 to +87
if len(ctx) == len(msgs):
return ctx[:keep]
if len(msgs) == 0:
return []
# Context tail aligns with full display transcript; preserve leading-only rows.
prefix_len = max(0, len(ctx) - len(msgs))

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.

P1 Unhandled alignment when len(ctx) < len(msgs). When context_messages is shorter than the display transcript (e.g. after partial context pruning or a future compaction strategy that drops old LLM-only rows), prefix_len becomes 0, so the function returns ctx[:keep] treating context as front-aligned with msgs. But the code's own comment says "Context tail aligns with full display transcript", meaning the correct slice for the shorter case would be ctx[len(ctx) - min(len(ctx), keep):]. As written, a fork at keep=1 with a 2-item ctx and 3-item msgs returns ctx[0] instead of ctx[-2] (the entry that actually corresponds to msgs[0]). Adding an explicit branch or at minimum a guard assertion would prevent silent mistruncation if this case is ever reachable.

Suggested change
if len(ctx) == len(msgs):
return ctx[:keep]
if len(msgs) == 0:
return []
# Context tail aligns with full display transcript; preserve leading-only rows.
prefix_len = max(0, len(ctx) - len(msgs))
if len(ctx) == len(msgs):
return ctx[:keep]
if len(msgs) == 0:
return []
if len(ctx) < len(msgs):
# Context is shorter than display — tail-aligned; slice from the aligned end.
aligned_keep = max(0, keep - (len(msgs) - len(ctx)))
return ctx[:aligned_keep]
# Context tail aligns with full display transcript; preserve leading-only rows.
prefix_len = len(ctx) - len(msgs)

Comment thread api/session_ops.py
Comment on lines +86 to +90
# Context tail aligns with full display transcript; preserve leading-only rows.
prefix_len = max(0, len(ctx) - len(msgs))
prefix = ctx[:prefix_len]
suffix = ctx[prefix_len:]
return prefix + suffix[:keep]

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.

P2 Compaction prefix always survives the fork regardless of keep — When prefix_len > 0, prefix + suffix[:keep] always includes all leading compaction-only rows even if keep=1. If a compaction summary was generated after the fork point (i.e. it summarises turns that include post-fork content), those rows will silently leak post-fork knowledge into the branch, which is the exact class of bug this PR targets. The PR notes this as a follow-up for context_engine_state, but the same window exists in the compaction prefix. Consider at minimum adding a test that asserts the expected behaviour so the contract is visible and a future compaction-aware truncation can be slotted in without regression.

]
out = truncate_context_for_display_keep(ctx, msgs, 2)
assert len(out) == 3
assert out[0]["content"] == "compaction-ref-only" No newline at end of file

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.

P2 Missing newline at end of file — most linters and git diff warn about this.

Suggested change
assert out[0]["content"] == "compaction-ref-only"
assert out[0]["content"] == "compaction-ref-only"

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@nesquena-hermes nesquena-hermes added the size:M Medium PR (≤10 files, ≤250 LOC) label Jun 28, 2026
@nesquena-hermes nesquena-hermes closed this pull request by merging all changes into nesquena:master in e3271b0 Jun 28, 2026
Paladin173 pushed a commit to Paladin173/hermes-webui that referenced this pull request Jun 28, 2026
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Shipped in v0.51.720 🎉 — thanks @claw-io. Forking now truncates context_messages to the fork prefix, so the model no longer sees post-fork turns. Verified through the full gate (Codex regression-safe + Opus + full suite green) and deployed. (#5096 Bug A)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M Medium PR (≤10 files, ≤250 LOC)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(session): rewind/fork leaves stale model context (branch, truncate, edit index)

3 participants