Skip to content

feat(session): add turn diff route - #47821

Open
nexxeln wants to merge 4 commits into
v2from
session-diff
Open

feat(session): add turn diff route#47821
nexxeln wants to merge 4 commits into
v2from
session-diff

Conversation

@nexxeln

@nexxeln nexxeln commented Sep 7, 2026

Copy link
Copy Markdown
Member

Supersedes #47795 (same feature, clean history).

summary

  • idle message (Session.Message.Idle, outcome: succeeded | failed | interrupted): projected when a busy period ends (session.execution.succeeded/failed/interrupted; shutdown is skipped because the resumed execution continues the same turn). Everything since the previous marker is one turn, steers included, so turns are derivable from session_message alone — no event persistence, no new table, no migration. Invisible to the model (toLLMMessage skips it) and to the TUI/web transcripts (skipped in reduceSessionRows and isNotice). The shared solid data layer synthesizes it live from the execution events like the other marker messages, so in-memory transcripts match the server before the next read; the UI footer anchoring is deliberately left for a follow-up.
  • GET /api/session/:sessionID/diff?messageID&to&context (session.diff) → { data: FileDiff.Info[] }: the files changed by the turn containing messageID (default: the newest user message's turn); to extends the range through a later user message's turn (contiguous, commit-range style). Compares the range's first recorded step snapshot with its last recorded one; only a step still running in the actively executing session compares against the working copy. Omitted context → full-file patches like vcs.diff. Snapshot trees are resolved from the Location in effect at the range (derived from location-switched messages via LocationServiceMap.get), so pre-move turns stay diffable and a range spanning a move is rejected. Errors: 400 InvalidRequestError (to before messageID, not a user message, location change), 404 MessageNotFoundError / SessionNotFoundError, 500 UnknownError with a logged ref on snapshot failure. Shared missingMessage / failedSnapshot handler helpers replace three inlined copies each.
  • Sessions with no idle marker at all (history from before this change, never resumed) fall back to prompt → next prompt. An old session that is resumed once merges its pre-marker history into the first marked turn — a superset, and transient; new sessions are always exact.
  • Git.tree.diff runs --name-status, --numstat, and the patch once per tree pair instead of three spawns per changed file (~50 ms/file before), caps patch output at MAX_TOTAL_PATCH_BYTES (capped files get an empty patch, stats stay exact), uses core.quotepath=false so non-ASCII paths still match their patch chunk, and passes --no-ext-diff. Snapshot.diff diffs first and filters ignored paths from the result instead of listing changed files twice. Revert benefits too.

Backend only; no TUI/app feature work in this PR.

Note for reviewers (not changed here): packages/core/src/session/stats.ts:296 filters EventTable.type with the unversioned SessionEvent.UsageRecorded.type while bus.ts stores versioned type names (session.usage.recorded.1), so that query cannot match persisted rows.

testing

  • bun typecheck in schema, protocol, core, server, client, plugin, tui, session-ui, app, cli, sdk, www.
  • core: full suite 5248 pass. New test/session-diff.test.ts is one scenario with real git snapshots: legacy prompt→prompt fallback, marker-era turn with a steer inside it (default and by the steer's message), pre-marker history merging into the first marked turn, ranges via to, error cases, unrecorded-end fallback, running-step working-copy diff only when active, revert (markers included), fork. test/git.test.ts covers the patch cap and the non-ASCII header match (assertion verified to fail without core.quotepath=false). One existing recovery test now excludes idle markers from its message-list assertion.
  • server: 57 pass incl. new test/session-diff.test.ts (200/400/404 contract). client 160 pass; tui data + diff-viewer 140 pass.
  • protocol generate-openapi.ts --check, www check:generated + build, prettier on touched files.

Git.tree.diff ran --name-status, --numstat, and a patch once per changed file, sequentially, so a turn or revert touching N files cost 1 + 3N git processes (~50ms per file). Run the three once over the tree pair, split the patch with VcsPatch.chunksByFile, cap patch output at MAX_TOTAL_PATCH_BYTES like VCS diffs (capped files get an empty patch, stats stay exact), keep core.quotepath=false so non-ASCII paths still match their chunk, and pass --no-ext-diff. Snapshot.diff diffs first and filters ignored paths from the result instead of listing changed files twice and passing every path as a pathspec.
Project an idle message when a busy period ends (execution succeeded, failed, or interrupted for any reason other than shutdown, which resumes the same turn). Every step since the previous marker is one turn, including prompts steered in while the Session was busy, so turns are derivable from session_message alone without persisting events or a separate table. The marker is invisible to the model and to the TUI and web transcripts.
GET /api/session/:sessionID/diff?messageID&to&context returns FileDiff.Info[] for the turn containing a user message (default: the newest one), or the contiguous range through a later user message's turn. A turn runs from the first prompt after the Session was last idle until its idle marker, so steers belong to the turn they interrupted; Sessions without markers fall back to prompt-to-next-prompt. The diff compares the range's first recorded step snapshot with its last recorded one, or with the working copy only while the Session is actively executing, resolves the snapshot repository from the Location in effect at the range (rejecting ranges that span a move), and defaults to full-file patches like vcs.diff. Shared missingMessage and failedSnapshot handler helpers replace the inlined mappings in the session handlers.
The solid data layer mirrors every projected marker message from its event so the in-memory transcript matches the server before the next read; do the same for the idle marker on execution succeeded, failed, and non-shutdown interrupted.
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.

1 participant