detached f4aadfc

fix(session): preserve pre-compaction history

commit
f4aadfc2847860fc9ef9a9d61065f90bab7d1eb1
author
brice · · 2026-08-04 12:23 UTC
committer
brice · · 2026-08-04 12:37 UTC
parent
f2b7e6e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +1030 -63
M README.md +4 -4
@@ -313,9 +313,9 @@ Four ops on the current session. `/session` alone opens a picker; each is also r
313313 **What's included.** Only the main chat thread of the running session — messages, tool calls, tool results, compactions, and usage. **`/swarm` subagents are NOT included.** Their transcripts, unix-socket inboxes, and per-agent session files are all machine-local; a `.zotsession` is just a chat transcript and has no way to revive a unix socket on another box. If you want the conversation, copy it out of the dashboard manually.
314314 - **`/session import <path>`**. Copies a `.zotsession` file into `$ZOT_HOME/sessions/<cwd-hash>/` with a fresh id and the current cwd, then switches the running agent onto it. Imported sessions are first-class: they show up in `/sessions` and `/jump`, and in the current-family tree when they are part of that family. Drag-drop paths in the editor are accepted (zot strips the surrounding quotes automatically).
315315 - **`/fork`** (also available as **`/session fork`**). Opens a turn picker (same shape as `/jump`). Pick any past user message; zot copies the transcript through that turn into a new branch and switches to it without starting a provider request. The parent session stays on disk. Use it to try a different question without polluting the original transcript, or to rewind after the agent went down the wrong path. Use a user-message row in `/session tree` when you want the prompt restored for editing, including image attachments.
316-- **`/session tree`**. Shows the current session's family: its root ancestor and descendants, not unrelated root sessions in the same cwd. Use `up`/`down` and `enter` to choose a row; `esc` closes the tree. Message rows are indented at their branch points and the current endpoint is tagged `[current]`. Empty and detached branch points are represented by visible boundary rows. A child whose historical fork point no longer matches after compaction stays discoverable as a detached branch under the parent's current tail rather than disappearing. Parentless sessions are roots; orphaned children (whose parent file was deleted) remain roots.
316+- **`/session tree`**. Shows the current session's family: its root ancestor and descendants, not unrelated root sessions in the same cwd. Use `up`/`down` and `enter` to choose a row; `esc` closes the tree. The picker keeps pre-compaction history available as forkable user and assistant messages, while hiding tool calls, tool results, compaction checkpoints, shell/image helper rows, and other internal context. Message rows are indented at their branch points and the current endpoint is tagged `[current]`. Empty and detached branch points are represented by visible boundary rows. A child whose historical fork point no longer matches after compaction stays discoverable as a detached branch under the parent's current tail rather than disappearing. Parentless sessions are roots; orphaned children (whose parent file was deleted) remain roots.
317317
318- Selecting a user-message row creates a navigation branch before that message and restores the complete editable draft, including text blocks and image attachments; no provider request is made until you submit it. Selecting an assistant/tool row checks out a safe message boundary, and selecting an empty or detached boundary checks out that branch point with an empty editor. Navigation branches are hidden from `/sessions` but remain in the tree.
318+ Selecting a user-message row creates a navigation branch before that message and restores the complete editable draft, including text blocks and image attachments; no provider request is made until you submit it. Selecting an assistant-message row checks out a safe message boundary, and selecting an empty or detached boundary checks out that branch point with an empty editor. Navigation branches are hidden from `/sessions` but remain in the tree.
319319
320320 `/session tree` and the double-Escape shortcut use the same checks: a complete current session family must be readable, and no turn, shell escape, compaction, startup/session load, dialog, picker, suggestion, or queued message may be active. The shortcut is two parsed, unmodified bare `Esc` events within 500 ms from an idle editor with no visible text or pending image. Dialogs, confirmations, panels, pickers, and suggestions keep their existing Escape behavior; busy Escape still cancels, modified Escape is not part of the gesture, and any intervening key or timeout resets it. The shortcut depends on the terminal input path producing those parsed events; `/session tree` is the explicit equivalent when it is unavailable.
321321
@@ -399,7 +399,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
399399 - **AI terminal titles** — after the first real prompt of a fresh interactive session, make one small hidden request to the active model and set the terminal title to `zot: <title>`. The title is limited to 40 Unicode characters, persisted with the session, restored on resume, and never added to the conversation. Enabled by default; disable it to avoid the extra model request. The toggle applies immediately, but title generation still waits for the first real prompt and never starts from startup context, resumed history, or slash commands. Persists as `terminal_title_enabled`.
400400 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and available named profiles are rendered in `[subagents_list]`. The tool accepts a profile name through `agent` and a per-child `reasoning`/`thinking` override. zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` and [docs/subagents.md](docs/subagents.md) for details.
401401 - **fast mode** — request OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; changes apply on the next model call and persist as `fast_mode`. Other providers return an unsupported-provider error. Fast mode may cost more and depends on the selected OpenAI model/account.
402-- **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses.
402+- **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. After a successful threshold compaction, zot automatically resumes an unfinished most-recent intent; a prompt queued during compaction takes priority. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses.
403403 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.
404404 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.
405405 - **show loaded resources at startup**: list the active Zotfile agent (for `zot run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.
@@ -416,7 +416,7 @@ Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each ro
416416
417417 Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Use it when the context meter creeps past ~80%.
418418
419-zot also auto-compacts in the background: after any turn that reaches the configured context threshold, the agent kicks off a condense pass on its own. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a context-window or payload-too-large response.
419+zot also auto-compacts in the background: after any turn that reaches the configured context threshold, the agent kicks off a condense pass on its own and, once it succeeds, resumes an unfinished most-recent intent automatically. Completed text answers settle normally, while a prompt queued during compaction runs instead of the automatic continuation. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a context-window or payload-too-large response.
420420
421421 ### `/jail`
422422
@@ -313,9 +313,9 @@ Four ops on the current session. `/session` alone opens a picker; each is also r
313 **What's included.** Only the main chat thread of the running session — messages, tool calls, tool results, compactions, and usage. **`/swarm` subagents are NOT included.** Their transcripts, unix-socket inboxes, and per-agent session files are all machine-local; a `.zotsession` is just a chat transcript and has no way to revive a unix socket on another box. If you want the conversation, copy it out of the dashboard manually.313 **What's included.** Only the main chat thread of the running session — messages, tool calls, tool results, compactions, and usage. **`/swarm` subagents are NOT included.** Their transcripts, unix-socket inboxes, and per-agent session files are all machine-local; a `.zotsession` is just a chat transcript and has no way to revive a unix socket on another box. If you want the conversation, copy it out of the dashboard manually.
314 - **`/session import <path>`**. Copies a `.zotsession` file into `$ZOT_HOME/sessions/<cwd-hash>/` with a fresh id and the current cwd, then switches the running agent onto it. Imported sessions are first-class: they show up in `/sessions` and `/jump`, and in the current-family tree when they are part of that family. Drag-drop paths in the editor are accepted (zot strips the surrounding quotes automatically).314 - **`/session import <path>`**. Copies a `.zotsession` file into `$ZOT_HOME/sessions/<cwd-hash>/` with a fresh id and the current cwd, then switches the running agent onto it. Imported sessions are first-class: they show up in `/sessions` and `/jump`, and in the current-family tree when they are part of that family. Drag-drop paths in the editor are accepted (zot strips the surrounding quotes automatically).
315 - **`/fork`** (also available as **`/session fork`**). Opens a turn picker (same shape as `/jump`). Pick any past user message; zot copies the transcript through that turn into a new branch and switches to it without starting a provider request. The parent session stays on disk. Use it to try a different question without polluting the original transcript, or to rewind after the agent went down the wrong path. Use a user-message row in `/session tree` when you want the prompt restored for editing, including image attachments.315 - **`/fork`** (also available as **`/session fork`**). Opens a turn picker (same shape as `/jump`). Pick any past user message; zot copies the transcript through that turn into a new branch and switches to it without starting a provider request. The parent session stays on disk. Use it to try a different question without polluting the original transcript, or to rewind after the agent went down the wrong path. Use a user-message row in `/session tree` when you want the prompt restored for editing, including image attachments.
316-- **`/session tree`**. Shows the current session's family: its root ancestor and descendants, not unrelated root sessions in the same cwd. Use `up`/`down` and `enter` to choose a row; `esc` closes the tree. Message rows are indented at their branch points and the current endpoint is tagged `[current]`. Empty and detached branch points are represented by visible boundary rows. A child whose historical fork point no longer matches after compaction stays discoverable as a detached branch under the parent's current tail rather than disappearing. Parentless sessions are roots; orphaned children (whose parent file was deleted) remain roots.316+- **`/session tree`**. Shows the current session's family: its root ancestor and descendants, not unrelated root sessions in the same cwd. Use `up`/`down` and `enter` to choose a row; `esc` closes the tree. The picker keeps pre-compaction history available as forkable user and assistant messages, while hiding tool calls, tool results, compaction checkpoints, shell/image helper rows, and other internal context. Message rows are indented at their branch points and the current endpoint is tagged `[current]`. Empty and detached branch points are represented by visible boundary rows. A child whose historical fork point no longer matches after compaction stays discoverable as a detached branch under the parent's current tail rather than disappearing. Parentless sessions are roots; orphaned children (whose parent file was deleted) remain roots.
317 317
318- Selecting a user-message row creates a navigation branch before that message and restores the complete editable draft, including text blocks and image attachments; no provider request is made until you submit it. Selecting an assistant/tool row checks out a safe message boundary, and selecting an empty or detached boundary checks out that branch point with an empty editor. Navigation branches are hidden from `/sessions` but remain in the tree.318+ Selecting a user-message row creates a navigation branch before that message and restores the complete editable draft, including text blocks and image attachments; no provider request is made until you submit it. Selecting an assistant-message row checks out a safe message boundary, and selecting an empty or detached boundary checks out that branch point with an empty editor. Navigation branches are hidden from `/sessions` but remain in the tree.
319 319
320 `/session tree` and the double-Escape shortcut use the same checks: a complete current session family must be readable, and no turn, shell escape, compaction, startup/session load, dialog, picker, suggestion, or queued message may be active. The shortcut is two parsed, unmodified bare `Esc` events within 500 ms from an idle editor with no visible text or pending image. Dialogs, confirmations, panels, pickers, and suggestions keep their existing Escape behavior; busy Escape still cancels, modified Escape is not part of the gesture, and any intervening key or timeout resets it. The shortcut depends on the terminal input path producing those parsed events; `/session tree` is the explicit equivalent when it is unavailable.320 `/session tree` and the double-Escape shortcut use the same checks: a complete current session family must be readable, and no turn, shell escape, compaction, startup/session load, dialog, picker, suggestion, or queued message may be active. The shortcut is two parsed, unmodified bare `Esc` events within 500 ms from an idle editor with no visible text or pending image. Dialogs, confirmations, panels, pickers, and suggestions keep their existing Escape behavior; busy Escape still cancels, modified Escape is not part of the gesture, and any intervening key or timeout resets it. The shortcut depends on the terminal input path producing those parsed events; `/session tree` is the explicit equivalent when it is unavailable.
321 321
@@ -399,7 +399,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
399 - **AI terminal titles** — after the first real prompt of a fresh interactive session, make one small hidden request to the active model and set the terminal title to `zot: <title>`. The title is limited to 40 Unicode characters, persisted with the session, restored on resume, and never added to the conversation. Enabled by default; disable it to avoid the extra model request. The toggle applies immediately, but title generation still waits for the first real prompt and never starts from startup context, resumed history, or slash commands. Persists as `terminal_title_enabled`.399 - **AI terminal titles** — after the first real prompt of a fresh interactive session, make one small hidden request to the active model and set the terminal title to `zot: <title>`. The title is limited to 40 Unicode characters, persisted with the session, restored on resume, and never added to the conversation. Enabled by default; disable it to avoid the extra model request. The toggle applies immediately, but title generation still waits for the first real prompt and never starts from startup context, resumed history, or slash commands. Persists as `terminal_title_enabled`.
400 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and available named profiles are rendered in `[subagents_list]`. The tool accepts a profile name through `agent` and a per-child `reasoning`/`thinking` override. zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` and [docs/subagents.md](docs/subagents.md) for details.400 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and available named profiles are rendered in `[subagents_list]`. The tool accepts a profile name through `agent` and a per-child `reasoning`/`thinking` override. zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` and [docs/subagents.md](docs/subagents.md) for details.
401 - **fast mode** — request OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; changes apply on the next model call and persist as `fast_mode`. Other providers return an unsupported-provider error. Fast mode may cost more and depends on the selected OpenAI model/account.401 - **fast mode** — request OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; changes apply on the next model call and persist as `fast_mode`. Other providers return an unsupported-provider error. Fast mode may cost more and depends on the selected OpenAI model/account.
402-- **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses.402+- **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. After a successful threshold compaction, zot automatically resumes an unfinished most-recent intent; a prompt queued during compaction takes priority. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses.
403 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.403 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.
404 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.404 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.
405 - **show loaded resources at startup**: list the active Zotfile agent (for `zot run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.405 - **show loaded resources at startup**: list the active Zotfile agent (for `zot run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.
@@ -416,7 +416,7 @@ Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each ro
416 416
417 Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Use it when the context meter creeps past ~80%.417 Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Use it when the context meter creeps past ~80%.
418 418
419-zot also auto-compacts in the background: after any turn that reaches the configured context threshold, the agent kicks off a condense pass on its own. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a context-window or payload-too-large response.419+zot also auto-compacts in the background: after any turn that reaches the configured context threshold, the agent kicks off a condense pass on its own and, once it succeeds, resumes an unfinished most-recent intent automatically. Completed text answers settle normally, while a prompt queued during compaction runs instead of the automatic continuation. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a context-window or payload-too-large response.
420 420
421 ### `/jail`421 ### `/jail`
422 422
M docs/zotfiles.md +1 -1
@@ -487,7 +487,7 @@ Sessions created by a zotfile are isolated from ordinary zot sessions and from o
487487 $ZOT_HOME/sessions/agents/<name>/
488488 ```
489489
490-Normal session flags still apply, including `--continue`, `--resume`, `--session`, and `--no-session`, but their default storage root is scoped to the active agent. The interactive `/sessions` picker and `/session` import, fork, and tree commands use that same agent-scoped root; `/fork` is the direct alias for selecting a user message and branching after that turn. `/session tree` shows the current session's family within that root (its ancestor and descendants), while tree-navigation branches stay hidden from the flat `/sessions` picker. Resuming honors the selected session's stored provider/model pair; a failed provider/model rebuild leaves the current session unchanged.
490+Normal session flags still apply, including `--continue`, `--resume`, `--session`, and `--no-session`, but their default storage root is scoped to the active agent. The interactive `/sessions` picker and `/session` import, fork, and tree commands use that same agent-scoped root; `/fork` is the direct alias for selecting a user message and branching after that turn. `/session tree` shows the current session's family within that root (its ancestor and descendants), keeps pre-compaction user and assistant history available for checkout, and hides tool/internal rows from the fork picker. Tree-navigation branches stay hidden from the flat `/sessions` picker. Resuming honors the selected session's stored provider/model pair; a failed provider/model rebuild leaves the current session unchanged.
491491
492492 ## Commands
493493
@@ -487,7 +487,7 @@ Sessions created by a zotfile are isolated from ordinary zot sessions and from o
487 $ZOT_HOME/sessions/agents/<name>/487 $ZOT_HOME/sessions/agents/<name>/
488 ```488 ```
489 489
490-Normal session flags still apply, including `--continue`, `--resume`, `--session`, and `--no-session`, but their default storage root is scoped to the active agent. The interactive `/sessions` picker and `/session` import, fork, and tree commands use that same agent-scoped root; `/fork` is the direct alias for selecting a user message and branching after that turn. `/session tree` shows the current session's family within that root (its ancestor and descendants), while tree-navigation branches stay hidden from the flat `/sessions` picker. Resuming honors the selected session's stored provider/model pair; a failed provider/model rebuild leaves the current session unchanged.490+Normal session flags still apply, including `--continue`, `--resume`, `--session`, and `--no-session`, but their default storage root is scoped to the active agent. The interactive `/sessions` picker and `/session` import, fork, and tree commands use that same agent-scoped root; `/fork` is the direct alias for selecting a user message and branching after that turn. `/session tree` shows the current session's family within that root (its ancestor and descendants), keeps pre-compaction user and assistant history available for checkout, and hides tool/internal rows from the fork picker. Tree-navigation branches stay hidden from the flat `/sessions` picker. Resuming honors the selected session's stored provider/model pair; a failed provider/model rebuild leaves the current session unchanged.
491 491
492 ## Commands492 ## Commands
493 493
M packages/agent/cli.go +4 -2
@@ -201,9 +201,11 @@ func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Mes
201201 addToolNames(msg.AddedToolNames)
202202 }
203203 // Preserve the synthetic compaction summary when present so an
204- // already-compacted session stays compacted after resume.
205- if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" && start > 1 {
204+ // already-compacted session stays compacted after resume. Count it
205+ // within the tail budget instead of dropping it at the exact boundary.
206+ if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" && start > 0 {
206207 out = append(out, msgs[0])
208+ start++
207209 }
208210 // Avoid hydrating a tail that starts with orphan tool_result rows;
209211 // provider APIs require those to be paired with an earlier tool_use.
@@ -201,9 +201,11 @@ func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Mes
201 addToolNames(msg.AddedToolNames)201 addToolNames(msg.AddedToolNames)
202 }202 }
203 // Preserve the synthetic compaction summary when present so an203 // Preserve the synthetic compaction summary when present so an
204- // already-compacted session stays compacted after resume.204+ // already-compacted session stays compacted after resume. Count it
205- if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" && start > 1 {205+ // within the tail budget instead of dropping it at the exact boundary.
206+ if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" && start > 0 {
206 out = append(out, msgs[0])207 out = append(out, msgs[0])
208+ start++
207 }209 }
208 // Avoid hydrating a tail that starts with orphan tool_result rows;210 // Avoid hydrating a tail that starts with orphan tool_result rows;
209 // provider APIs require those to be paired with an earlier tool_use.211 // provider APIs require those to be paired with an earlier tool_use.
M packages/agent/cli_session_test.go +30 -0
@@ -3,6 +3,7 @@ package agent
33 import (
44 "context"
55 "errors"
6+ "fmt"
67 "os"
78 "testing"
89
@@ -33,6 +34,35 @@ func TestTrimMessagesForResumeCarriesDeferredToolActivation(t *testing.T) {
3334 }
3435 }
3536
37+func TestTrimMessagesForResumeKeepsCompactionSummaryAtTailBoundary(t *testing.T) {
38+ for _, total := range []int{100, 101, 102} {
39+ t.Run(fmt.Sprintf("%d messages", total), func(t *testing.T) {
40+ msgs := make([]provider.Message, 0, total)
41+ msgs = append(msgs, provider.Message{
42+ Role: provider.RoleUser,
43+ Meta: map[string]string{"compaction": "true"},
44+ Content: []provider.Content{
45+ provider.TextBlock{Text: "## Context Summary (compacted)"},
46+ },
47+ })
48+ for idx := 1; idx < total; idx++ {
49+ msgs = append(msgs, provider.Message{
50+ Role: provider.RoleUser,
51+ Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("message-%d", idx)}},
52+ })
53+ }
54+
55+ trimmed := trimMessagesForResume(msgs, 100)
56+ if len(trimmed) > 100 {
57+ t.Fatalf("trimmed message count = %d, want at most 100", len(trimmed))
58+ }
59+ if len(trimmed) == 0 || trimmed[0].Meta["compaction"] != "true" {
60+ t.Fatalf("trimmed transcript lost compaction summary: %+v", trimmed)
61+ }
62+ })
63+ }
64+}
65+
3666 func TestLiveInteractiveAgentUsesReplacementAgentForSessionResume(t *testing.T) {
3767 startup := core.NewAgent(nil, "startup-model", "", nil)
3868 startup.SetMessages([]provider.Message{{
@@ -3,6 +3,7 @@ package agent
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6+ "fmt"
6 "os"7 "os"
7 "testing"8 "testing"
8 9
@@ -33,6 +34,35 @@ func TestTrimMessagesForResumeCarriesDeferredToolActivation(t *testing.T) {
33 }34 }
34 }35 }
35 36
37+func TestTrimMessagesForResumeKeepsCompactionSummaryAtTailBoundary(t *testing.T) {
38+ for _, total := range []int{100, 101, 102} {
39+ t.Run(fmt.Sprintf("%d messages", total), func(t *testing.T) {
40+ msgs := make([]provider.Message, 0, total)
41+ msgs = append(msgs, provider.Message{
42+ Role: provider.RoleUser,
43+ Meta: map[string]string{"compaction": "true"},
44+ Content: []provider.Content{
45+ provider.TextBlock{Text: "## Context Summary (compacted)"},
46+ },
47+ })
48+ for idx := 1; idx < total; idx++ {
49+ msgs = append(msgs, provider.Message{
50+ Role: provider.RoleUser,
51+ Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("message-%d", idx)}},
52+ })
53+ }
54+
55+ trimmed := trimMessagesForResume(msgs, 100)
56+ if len(trimmed) > 100 {
57+ t.Fatalf("trimmed message count = %d, want at most 100", len(trimmed))
58+ }
59+ if len(trimmed) == 0 || trimmed[0].Meta["compaction"] != "true" {
60+ t.Fatalf("trimmed transcript lost compaction summary: %+v", trimmed)
61+ }
62+ })
63+ }
64+}
65+
36 func TestLiveInteractiveAgentUsesReplacementAgentForSessionResume(t *testing.T) {66 func TestLiveInteractiveAgentUsesReplacementAgentForSessionResume(t *testing.T) {
37 startup := core.NewAgent(nil, "startup-model", "", nil)67 startup := core.NewAgent(nil, "startup-model", "", nil)
38 startup.SetMessages([]provider.Message{{68 startup.SetMessages([]provider.Message{{
M packages/agent/modes/auto_compact_test.go +55 -1
@@ -1,6 +1,10 @@
11 package modes
22
3-import "testing"
3+import (
4+ "testing"
5+
6+ "github.com/patriceckhart/zot/packages/provider"
7+)
48
59 func TestNormalizeAutoCompactThreshold(t *testing.T) {
610 tests := []struct {
@@ -27,6 +31,56 @@ func TestNormalizeAutoCompactThreshold(t *testing.T) {
2731 }
2832 }
2933
34+func TestShouldAutoContinueAfterCompaction(t *testing.T) {
35+ tests := []struct {
36+ name string
37+ msgs []provider.Message
38+ want bool
39+ }{
40+ {
41+ name: "completed text answer",
42+ msgs: []provider.Message{{
43+ Role: provider.RoleAssistant,
44+ Content: []provider.Content{provider.TextBlock{Text: "done"}},
45+ }},
46+ want: false,
47+ },
48+ {
49+ name: "reasoning only",
50+ msgs: []provider.Message{{
51+ Role: provider.RoleAssistant,
52+ Content: []provider.Content{provider.ReasoningBlock{Summary: "still working"}},
53+ }},
54+ want: true,
55+ },
56+ {
57+ name: "tool call",
58+ msgs: []provider.Message{{
59+ Role: provider.RoleAssistant,
60+ Content: []provider.Content{provider.ToolCallBlock{Name: "read"}},
61+ }},
62+ want: true,
63+ },
64+ {
65+ name: "user tail",
66+ msgs: []provider.Message{{
67+ Role: provider.RoleUser,
68+ Content: []provider.Content{provider.TextBlock{Text: "continue"}},
69+ }},
70+ want: true,
71+ },
72+ {name: "empty", want: false},
73+ }
74+
75+ for _, tt := range tests {
76+ t.Run(tt.name, func(t *testing.T) {
77+ if got := shouldAutoContinueAfterCompaction(tt.msgs); got != tt.want {
78+ t.Fatalf("shouldAutoContinueAfterCompaction() = %t, want %t", got, tt.want)
79+ }
80+ })
81+ }
82+}
83+
3084 func TestShouldAutoCompactUsesConfiguredThreshold(t *testing.T) {
3185 tests := []struct {
3286 name string
@@ -1,6 +1,10 @@
1 package modes1 package modes
2 2
3-import "testing"3+import (
4+ "testing"
5+
6+ "github.com/patriceckhart/zot/packages/provider"
7+)
4 8
5 func TestNormalizeAutoCompactThreshold(t *testing.T) {9 func TestNormalizeAutoCompactThreshold(t *testing.T) {
6 tests := []struct {10 tests := []struct {
@@ -27,6 +31,56 @@ func TestNormalizeAutoCompactThreshold(t *testing.T) {
27 }31 }
28 }32 }
29 33
34+func TestShouldAutoContinueAfterCompaction(t *testing.T) {
35+ tests := []struct {
36+ name string
37+ msgs []provider.Message
38+ want bool
39+ }{
40+ {
41+ name: "completed text answer",
42+ msgs: []provider.Message{{
43+ Role: provider.RoleAssistant,
44+ Content: []provider.Content{provider.TextBlock{Text: "done"}},
45+ }},
46+ want: false,
47+ },
48+ {
49+ name: "reasoning only",
50+ msgs: []provider.Message{{
51+ Role: provider.RoleAssistant,
52+ Content: []provider.Content{provider.ReasoningBlock{Summary: "still working"}},
53+ }},
54+ want: true,
55+ },
56+ {
57+ name: "tool call",
58+ msgs: []provider.Message{{
59+ Role: provider.RoleAssistant,
60+ Content: []provider.Content{provider.ToolCallBlock{Name: "read"}},
61+ }},
62+ want: true,
63+ },
64+ {
65+ name: "user tail",
66+ msgs: []provider.Message{{
67+ Role: provider.RoleUser,
68+ Content: []provider.Content{provider.TextBlock{Text: "continue"}},
69+ }},
70+ want: true,
71+ },
72+ {name: "empty", want: false},
73+ }
74+
75+ for _, tt := range tests {
76+ t.Run(tt.name, func(t *testing.T) {
77+ if got := shouldAutoContinueAfterCompaction(tt.msgs); got != tt.want {
78+ t.Fatalf("shouldAutoContinueAfterCompaction() = %t, want %t", got, tt.want)
79+ }
80+ })
81+ }
82+}
83+
30 func TestShouldAutoCompactUsesConfiguredThreshold(t *testing.T) {84 func TestShouldAutoCompactUsesConfiguredThreshold(t *testing.T) {
31 tests := []struct {85 tests := []struct {
32 name string86 name string
M packages/agent/modes/compact_queue_test.go +91 -7
@@ -94,6 +94,93 @@ func TestPromptSubmittedDuringCompactionStartsFollowUpTurn(t *testing.T) {
9494 }
9595 }
9696
97+type thresholdAutoCompactClient struct {
98+ compactionStarted chan struct{}
99+ releaseCompaction chan struct{}
100+ followUpRequest chan provider.Request
101+
102+ mu sync.Mutex
103+ calls int
104+}
105+
106+func (c *thresholdAutoCompactClient) Name() string { return "threshold-auto-compact-test" }
107+
108+func (c *thresholdAutoCompactClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
109+ c.mu.Lock()
110+ c.calls++
111+ call := c.calls
112+ c.mu.Unlock()
113+
114+ out := make(chan provider.Event, 3)
115+ go func() {
116+ defer close(out)
117+ switch call {
118+ case 1:
119+ out <- provider.EventUsage{Usage: provider.Usage{InputTokens: 150000}}
120+ out <- provider.EventDone{
121+ Stop: provider.StopEnd,
122+ Message: provider.Message{
123+ Role: provider.RoleAssistant,
124+ Content: []provider.Content{provider.ReasoningBlock{Summary: "still working"}},
125+ },
126+ }
127+ case 2:
128+ close(c.compactionStarted)
129+ select {
130+ case <-ctx.Done():
131+ out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()}
132+ case <-c.releaseCompaction:
133+ out <- provider.EventTextDelta{Delta: "summary"}
134+ out <- provider.EventDone{Stop: provider.StopEnd}
135+ }
136+ default:
137+ c.followUpRequest <- req
138+ out <- provider.EventDone{
139+ Stop: provider.StopEnd,
140+ Message: provider.Message{
141+ Role: provider.RoleAssistant,
142+ Content: []provider.Content{provider.TextBlock{Text: "continued"}},
143+ },
144+ }
145+ }
146+ }()
147+ return out, nil
148+}
149+
150+func TestThresholdAutoCompactionContinuesMostRecentIntent(t *testing.T) {
151+ client := &thresholdAutoCompactClient{
152+ compactionStarted: make(chan struct{}),
153+ releaseCompaction: make(chan struct{}),
154+ followUpRequest: make(chan provider.Request, 1),
155+ }
156+ agent := core.NewAgent(client, "test-model", "", nil)
157+ threshold := 70
158+ interactive := NewInteractive(InteractiveConfig{
159+ Agent: agent,
160+ Provider: "anthropic",
161+ Model: "claude-sonnet-4-5-20250929",
162+ AutoCompactThreshold: &threshold,
163+ })
164+ interactive.runCtx = context.Background()
165+
166+ interactive.startTurn(context.Background(), "initial request")
167+ select {
168+ case <-client.compactionStarted:
169+ case <-time.After(2 * time.Second):
170+ t.Fatal("threshold auto-compaction did not start")
171+ }
172+ close(client.releaseCompaction)
173+
174+ select {
175+ case req := <-client.followUpRequest:
176+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
177+ t.Fatalf("continuation request contains auto-continue prompt %d times, want 1: %#v", got, req.Messages)
178+ }
179+ case <-time.After(2 * time.Second):
180+ t.Fatal("threshold auto-compaction did not continue the most recent intent")
181+ }
182+}
183+
97184 func TestPreTurnCompactionPreservesPromptImages(t *testing.T) {
98185 client := &compactQueueClient{
99186 compactionStarted: make(chan struct{}),
@@ -101,13 +188,10 @@ func TestPreTurnCompactionPreservesPromptImages(t *testing.T) {
101188 followUpRequest: make(chan provider.Request, 1),
102189 }
103190 agent := core.NewAgent(client, "test-model", "", nil)
104- agent.SetMessages([]provider.Message{
105- {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
106- {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
107- {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
108- {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
109- {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "five"}}},
110- })
191+ agent.SetMessages([]provider.Message{{
192+ Role: provider.RoleUser,
193+ Content: []provider.Content{provider.TextBlock{Text: "one"}},
194+ }})
111195 threshold := 70
112196 interactive := NewInteractive(InteractiveConfig{
113197 Agent: agent,
@@ -94,6 +94,93 @@ func TestPromptSubmittedDuringCompactionStartsFollowUpTurn(t *testing.T) {
94 }94 }
95 }95 }
96 96
97+type thresholdAutoCompactClient struct {
98+ compactionStarted chan struct{}
99+ releaseCompaction chan struct{}
100+ followUpRequest chan provider.Request
101+
102+ mu sync.Mutex
103+ calls int
104+}
105+
106+func (c *thresholdAutoCompactClient) Name() string { return "threshold-auto-compact-test" }
107+
108+func (c *thresholdAutoCompactClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
109+ c.mu.Lock()
110+ c.calls++
111+ call := c.calls
112+ c.mu.Unlock()
113+
114+ out := make(chan provider.Event, 3)
115+ go func() {
116+ defer close(out)
117+ switch call {
118+ case 1:
119+ out <- provider.EventUsage{Usage: provider.Usage{InputTokens: 150000}}
120+ out <- provider.EventDone{
121+ Stop: provider.StopEnd,
122+ Message: provider.Message{
123+ Role: provider.RoleAssistant,
124+ Content: []provider.Content{provider.ReasoningBlock{Summary: "still working"}},
125+ },
126+ }
127+ case 2:
128+ close(c.compactionStarted)
129+ select {
130+ case <-ctx.Done():
131+ out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()}
132+ case <-c.releaseCompaction:
133+ out <- provider.EventTextDelta{Delta: "summary"}
134+ out <- provider.EventDone{Stop: provider.StopEnd}
135+ }
136+ default:
137+ c.followUpRequest <- req
138+ out <- provider.EventDone{
139+ Stop: provider.StopEnd,
140+ Message: provider.Message{
141+ Role: provider.RoleAssistant,
142+ Content: []provider.Content{provider.TextBlock{Text: "continued"}},
143+ },
144+ }
145+ }
146+ }()
147+ return out, nil
148+}
149+
150+func TestThresholdAutoCompactionContinuesMostRecentIntent(t *testing.T) {
151+ client := &thresholdAutoCompactClient{
152+ compactionStarted: make(chan struct{}),
153+ releaseCompaction: make(chan struct{}),
154+ followUpRequest: make(chan provider.Request, 1),
155+ }
156+ agent := core.NewAgent(client, "test-model", "", nil)
157+ threshold := 70
158+ interactive := NewInteractive(InteractiveConfig{
159+ Agent: agent,
160+ Provider: "anthropic",
161+ Model: "claude-sonnet-4-5-20250929",
162+ AutoCompactThreshold: &threshold,
163+ })
164+ interactive.runCtx = context.Background()
165+
166+ interactive.startTurn(context.Background(), "initial request")
167+ select {
168+ case <-client.compactionStarted:
169+ case <-time.After(2 * time.Second):
170+ t.Fatal("threshold auto-compaction did not start")
171+ }
172+ close(client.releaseCompaction)
173+
174+ select {
175+ case req := <-client.followUpRequest:
176+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
177+ t.Fatalf("continuation request contains auto-continue prompt %d times, want 1: %#v", got, req.Messages)
178+ }
179+ case <-time.After(2 * time.Second):
180+ t.Fatal("threshold auto-compaction did not continue the most recent intent")
181+ }
182+}
183+
97 func TestPreTurnCompactionPreservesPromptImages(t *testing.T) {184 func TestPreTurnCompactionPreservesPromptImages(t *testing.T) {
98 client := &compactQueueClient{185 client := &compactQueueClient{
99 compactionStarted: make(chan struct{}),186 compactionStarted: make(chan struct{}),
@@ -101,13 +188,10 @@ func TestPreTurnCompactionPreservesPromptImages(t *testing.T) {
101 followUpRequest: make(chan provider.Request, 1),188 followUpRequest: make(chan provider.Request, 1),
102 }189 }
103 agent := core.NewAgent(client, "test-model", "", nil)190 agent := core.NewAgent(client, "test-model", "", nil)
104- agent.SetMessages([]provider.Message{191+ agent.SetMessages([]provider.Message{{
105- {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},192+ Role: provider.RoleUser,
106- {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},193+ Content: []provider.Content{provider.TextBlock{Text: "one"}},
107- {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},194+ }})
108- {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
109- {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "five"}}},
110- })
111 threshold := 70195 threshold := 70
112 interactive := NewInteractive(InteractiveConfig{196 interactive := NewInteractive(InteractiveConfig{
113 Agent: agent,197 Agent: agent,
M packages/agent/modes/input_history_test.go +34 -0
@@ -64,6 +64,40 @@ func TestInputHistorySkipsShellEscapeContext(t *testing.T) {
6464 }
6565 }
6666
67+func TestInputHistorySkipsCompactionContext(t *testing.T) {
68+ ag := core.NewAgent(nil, "", "", nil)
69+ ag.SetMessages([]provider.Message{
70+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prompt"}}},
71+ {Role: provider.RoleUser, Meta: map[string]string{"compaction": "true"}, Content: []provider.Content{provider.TextBlock{Text: "## Context Summary (compacted)"}}},
72+ {Role: provider.RoleUser, Meta: map[string]string{autoCompactContinueMetaKey: "true"}, Content: []provider.Content{provider.TextBlock{Text: "resume internally"}}},
73+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "new prompt"}}},
74+ })
75+ i := &Interactive{agent: ag}
76+
77+ history := i.inputHistory()
78+ if len(history) != 2 || history[0] != "prompt" || history[1] != "new prompt" {
79+ t.Fatalf("inputHistory() = %q, want [prompt new prompt]", history)
80+ }
81+}
82+
83+func TestJumpTargetsSkipInternalUserContext(t *testing.T) {
84+ msgs := []provider.Message{
85+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prompt"}}},
86+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "read"}}},
87+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{CallID: "call-1"}}},
88+ {Role: provider.RoleUser, Meta: map[string]string{"compaction": "true"}, Content: []provider.Content{provider.TextBlock{Text: "## Context Summary (compacted)"}}},
89+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "new prompt"}}},
90+ }
91+
92+ targets := buildJumpTargets(msgs)
93+ if len(targets) != 2 {
94+ t.Fatalf("jump target count = %d, want 2", len(targets))
95+ }
96+ if targets[0].TurnNo != 1 || targets[0].ToolCount != 1 || targets[1].TurnNo != 2 {
97+ t.Fatalf("jump targets = %+v, want real turns and tool count", targets)
98+ }
99+}
100+
67101 func TestInputHistoryNoLongerUsesLeftRight(t *testing.T) {
68102 ag := core.NewAgent(nil, "", "", nil)
69103 ag.SetMessages([]provider.Message{
@@ -64,6 +64,40 @@ func TestInputHistorySkipsShellEscapeContext(t *testing.T) {
64 }64 }
65 }65 }
66 66
67+func TestInputHistorySkipsCompactionContext(t *testing.T) {
68+ ag := core.NewAgent(nil, "", "", nil)
69+ ag.SetMessages([]provider.Message{
70+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prompt"}}},
71+ {Role: provider.RoleUser, Meta: map[string]string{"compaction": "true"}, Content: []provider.Content{provider.TextBlock{Text: "## Context Summary (compacted)"}}},
72+ {Role: provider.RoleUser, Meta: map[string]string{autoCompactContinueMetaKey: "true"}, Content: []provider.Content{provider.TextBlock{Text: "resume internally"}}},
73+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "new prompt"}}},
74+ })
75+ i := &Interactive{agent: ag}
76+
77+ history := i.inputHistory()
78+ if len(history) != 2 || history[0] != "prompt" || history[1] != "new prompt" {
79+ t.Fatalf("inputHistory() = %q, want [prompt new prompt]", history)
80+ }
81+}
82+
83+func TestJumpTargetsSkipInternalUserContext(t *testing.T) {
84+ msgs := []provider.Message{
85+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prompt"}}},
86+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "read"}}},
87+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{CallID: "call-1"}}},
88+ {Role: provider.RoleUser, Meta: map[string]string{"compaction": "true"}, Content: []provider.Content{provider.TextBlock{Text: "## Context Summary (compacted)"}}},
89+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "new prompt"}}},
90+ }
91+
92+ targets := buildJumpTargets(msgs)
93+ if len(targets) != 2 {
94+ t.Fatalf("jump target count = %d, want 2", len(targets))
95+ }
96+ if targets[0].TurnNo != 1 || targets[0].ToolCount != 1 || targets[1].TurnNo != 2 {
97+ t.Fatalf("jump targets = %+v, want real turns and tool count", targets)
98+ }
99+}
100+
67 func TestInputHistoryNoLongerUsesLeftRight(t *testing.T) {101 func TestInputHistoryNoLongerUsesLeftRight(t *testing.T) {
68 ag := core.NewAgent(nil, "", "", nil)102 ag := core.NewAgent(nil, "", "", nil)
69 ag.SetMessages([]provider.Message{103 ag.SetMessages([]provider.Message{
M packages/agent/modes/interactive.go +133 -16
@@ -1889,6 +1889,7 @@ func snapViewportStartToImageBlock(chat []string, start int) int {
18891889
18901890 const (
18911891 hiddenOpenAIImageMirrorPrefix = "Tool output included the following image content:"
1892+ autoCompactContinueMetaKey = "auto_compact_continue"
18921893 shellEscapeMetaKey = "shell_escape"
18931894 )
18941895
@@ -1907,6 +1908,9 @@ func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message
19071908 }
19081909
19091910 func isHiddenTranscriptMessage(m provider.Message) bool {
1911+ if m.Meta[autoCompactContinueMetaKey] == "true" {
1912+ return true
1913+ }
19101914 if m.Role != provider.RoleUser || len(m.Content) == 0 {
19111915 return false
19121916 }
@@ -3029,7 +3033,7 @@ func (i *Interactive) inputHistory() []string {
30293033 msgs := i.agent.Messages()
30303034 hist := make([]string, 0, len(msgs))
30313035 for _, m := range msgs {
3032- if m.Role != provider.RoleUser || isHiddenTranscriptMessage(m) || m.Meta[shellEscapeMetaKey] == "true" {
3036+ if !isForkableUserMessage(m) {
30333037 continue
30343038 }
30353039 text := userMessageText(m)
@@ -5793,6 +5797,32 @@ func (i *Interactive) clearPendingCompactTurnLocked() {
57935797 i.pendingPostCompactNote = ""
57945798 }
57955799
5800+const autoCompactContinuationPrompt = `Resume work on the user's most recent intent. Re-read the kept recent messages after the summary to confirm what the user asked for last. If their latest request supersedes earlier plans recorded in the summary, follow the latest request. If there is nothing left to do, say so briefly instead of inventing further work.`
5801+
5802+// startAutoCompactContinuation adds an internal user turn that tells the
5803+// model to resume the task after threshold compaction. Provider requests
5804+// cannot continue from an assistant tail on every supported API, so the
5805+// continuation must be represented as a user message. It is hidden from
5806+// the rendered transcript but remains in the persisted/model context.
5807+func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
5808+ i.mu.Lock()
5809+ ag := i.agent
5810+ i.mu.Unlock()
5811+ if ag == nil {
5812+ i.mu.Lock()
5813+ i.busy = false
5814+ i.mu.Unlock()
5815+ i.invalidate()
5816+ return
5817+ }
5818+ if ag.QueuedMessageCount() == 0 {
5819+ ag.AppendUserContext(autoCompactContinuationPrompt, map[string]string{
5820+ autoCompactContinueMetaKey: "true",
5821+ })
5822+ }
5823+ i.startTurnRequest(parent, "", nil, true, false)
5824+}
5825+
57965826 // runCompact invokes core.Agent.Compact and reflects the progress in
57975827 // the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
57985828 // cancel via the same cancelTurn channel used for normal turns.
@@ -5831,7 +5861,18 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
58315861 go func() {
58325862 // Sink discards deltas — we don't stream the summary to the UI.
58335863 sink := func(delta string) {}
5834- summary, err := i.agent.Compact(ctx, 4, sink)
5864+ msgsBefore := i.agent.Messages()
5865+ autoContinue := auto && shouldAutoContinueAfterCompaction(msgsBefore)
5866+ // Keep the usual recent tail when possible, but never let it cover
5867+ // the whole transcript. A short session can still be at 70–90% of
5868+ // a model's window because one prompt or tool result is large; the
5869+ // automatic path must summarize that session instead of failing
5870+ // before it reaches the compaction provider.
5871+ keepTail := 4
5872+ if n := len(msgsBefore); n > 0 && keepTail >= n {
5873+ keepTail = n - 1
5874+ }
5875+ summary, err := i.agent.Compact(ctx, keepTail, sink)
58355876 _ = summary
58365877 i.mu.Lock()
58375878 i.busy = false
@@ -5848,6 +5889,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
58485889 var nextImages []provider.ImageBlock
58495890 var hasNext bool
58505891 var continueExisting bool
5892+ var continueAutomatically bool
58515893
58525894 switch {
58535895 case err != nil && ctx.Err() != nil:
@@ -5907,20 +5949,35 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
59075949 case len(i.queued) > 0:
59085950 next, i.queued = i.queued[0], i.queued[1:]
59095951 hasNext = true
5952+ case autoContinue:
5953+ // A successful threshold compaction must hand control back to
5954+ // an unfinished model turn. Completed text answers should settle
5955+ // without an invented extra request; the user can still prompt
5956+ // again normally.
5957+ continueAutomatically = true
59105958 }
59115959 }
5960+ if hasNext || continueExisting || continueAutomatically {
5961+ // Keep the host busy while handing the next turn back to the
5962+ // agent. Prompts submitted in this hand-off window then enter
5963+ // the agent queue instead of racing a second startTurn call.
5964+ i.busy = true
5965+ }
59125966 i.mu.Unlock()
59135967 i.invalidate()
59145968
5915- if hasNext || continueExisting {
5969+ if hasNext || continueExisting || continueAutomatically {
59165970 p := i.runCtx
59175971 if p == nil {
59185972 p = context.Background()
59195973 }
5920- if continueExisting {
5921- i.startTurnRequest(p, "", nil, true)
5922- } else {
5974+ switch {
5975+ case continueExisting:
5976+ i.startTurnRequest(p, "", nil, true, true)
5977+ case hasNext:
59235978 i.startTurnWithImages(p, next, nextImages)
5979+ case continueAutomatically:
5980+ i.startAutoCompactContinuation(p)
59245981 }
59255982 }
59265983 }()
@@ -6055,10 +6112,16 @@ func (i *Interactive) startTurn(parent context.Context, prompt string) {
60556112 }
60566113
60576114 func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {
6058- i.startTurnRequest(parent, prompt, images, false)
6115+ i.startTurnRequest(parent, prompt, images, false, false)
60596116 }
60606117
6061-func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, overflowRecoveryAttempted bool) {
6118+// startTurnRequest starts a new prompt or continues an existing user turn.
6119+// continueExisting selects Agent.Continue so callers can retry a transcript
6120+// message without appending it twice. overflowRecoveryAttempted suppresses
6121+// the pre-turn threshold guard and the one-shot overflow recovery path; a
6122+// normal post-compaction continuation leaves it false so a still-too-large
6123+// rebuilt context can recover once more.
6124+func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, continueExisting, overflowRecoveryAttempted bool) {
60626125 if i.agent == nil {
60636126 // Text startup pre cannot run without credentials; continue so
60646127 // deferred InitialInput (pre-fill or auto-submit) still applies.
@@ -6148,7 +6211,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
61486211
61496212 go func() {
61506213 var err error
6151- if overflowRecoveryAttempted {
6214+ if continueExisting {
61526215 err = i.agent.Continue(ctx, sink)
61536216 } else {
61546217 err = i.agent.Prompt(ctx, prompt, images, sink)
@@ -6402,6 +6465,36 @@ func shouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool {
64026465 return float64(inputTokens)/float64(contextWindow) >= float64(thresholdPercent)/100
64036466 }
64046467
6468+// shouldAutoContinueAfterCompaction reports whether the transcript tail looks
6469+// unfinished. A complete assistant text answer should not cause an extra
6470+// model turn merely because its context was compacted; tool calls, reasoning-
6471+// only replies, and non-assistant tails still need the model to resume.
6472+func shouldAutoContinueAfterCompaction(msgs []provider.Message) bool {
6473+ if len(msgs) == 0 {
6474+ return false
6475+ }
6476+ last := msgs[len(msgs)-1]
6477+ if last.Role != provider.RoleAssistant {
6478+ return true
6479+ }
6480+ hasText := false
6481+ for _, content := range last.Content {
6482+ switch block := content.(type) {
6483+ case provider.ToolCallBlock:
6484+ return true
6485+ case provider.TextBlock:
6486+ if strings.TrimSpace(block.Text) != "" {
6487+ hasText = true
6488+ }
6489+ case provider.ReasoningBlock:
6490+ // Reasoning without a visible answer is not terminal.
6491+ default:
6492+ return true
6493+ }
6494+ }
6495+ return !hasText
6496+}
6497+
64056498 // shouldAutoCompactLocked reports whether the last turn pushed context
64066499 // usage past the auto-compact threshold. Must be called with i.mu
64076500 // held; it reads lastCtxInput and the current model's context window.
@@ -7469,13 +7562,32 @@ func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo in
74697562
74707563 // Read and validate the selected row before flushing or creating anything.
74717564 // This gives tool-call/result pairs an atomic boundary and prevents a
7472- // malformed selection from changing the active session.
7473- sess, msgs, err := core.OpenSession(src)
7474- if err != nil {
7475- i.setSessionTreeError("tree: read selection: " + err.Error())
7476- return
7565+ // malformed selection from changing the active session. Historical rows
7566+ // come from a pre-compaction segment; current rows use the effective
7567+ // snapshot that the running agent resumes from.
7568+ var msgs []provider.Message
7569+ var historicalSegment core.SessionHistorySegment
7570+ if target.Historical {
7571+ history, err := core.ReadSessionHistory(src)
7572+ if err != nil {
7573+ i.setSessionTreeError("tree: read selection: " + err.Error())
7574+ return
7575+ }
7576+ if target.HistorySegment < 0 || target.HistorySegment >= len(history.Segments) {
7577+ i.setSessionTreeError("tree: selected history segment is unavailable")
7578+ return
7579+ }
7580+ historicalSegment = history.Segments[target.HistorySegment]
7581+ msgs = historicalSegment.Messages
7582+ } else {
7583+ sess, current, err := core.OpenSession(src)
7584+ if err != nil {
7585+ i.setSessionTreeError("tree: read selection: " + err.Error())
7586+ return
7587+ }
7588+ _ = sess.Close()
7589+ msgs = current
74777590 }
7478- _ = sess.Close()
74797591 selection, err := sessionTreeSelection(msgs, target)
74807592 if err != nil {
74817593 i.setSessionTreeError("tree: " + err.Error())
@@ -7484,7 +7596,12 @@ func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo in
74847596 if i.cfg.FlushSession != nil {
74857597 i.cfg.FlushSession()
74867598 }
7487- newPath, err := core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, selection.upTo)
7599+ var newPath string
7600+ if target.Historical {
7601+ newPath, err = core.BranchSessionHiddenFromHistory(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, historicalSegment, selection.upTo)
7602+ } else {
7603+ newPath, err = core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, selection.upTo)
7604+ }
74887605 if err != nil {
74897606 i.setSessionTreeError("tree: " + err.Error())
74907607 return
@@ -1889,6 +1889,7 @@ func snapViewportStartToImageBlock(chat []string, start int) int {
1889 1889
1890 const (1890 const (
1891 hiddenOpenAIImageMirrorPrefix = "Tool output included the following image content:"1891 hiddenOpenAIImageMirrorPrefix = "Tool output included the following image content:"
1892+ autoCompactContinueMetaKey = "auto_compact_continue"
1892 shellEscapeMetaKey = "shell_escape"1893 shellEscapeMetaKey = "shell_escape"
1893 )1894 )
1894 1895
@@ -1907,6 +1908,9 @@ func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message
1907 }1908 }
1908 1909
1909 func isHiddenTranscriptMessage(m provider.Message) bool {1910 func isHiddenTranscriptMessage(m provider.Message) bool {
1911+ if m.Meta[autoCompactContinueMetaKey] == "true" {
1912+ return true
1913+ }
1910 if m.Role != provider.RoleUser || len(m.Content) == 0 {1914 if m.Role != provider.RoleUser || len(m.Content) == 0 {
1911 return false1915 return false
1912 }1916 }
@@ -3029,7 +3033,7 @@ func (i *Interactive) inputHistory() []string {
3029 msgs := i.agent.Messages()3033 msgs := i.agent.Messages()
3030 hist := make([]string, 0, len(msgs))3034 hist := make([]string, 0, len(msgs))
3031 for _, m := range msgs {3035 for _, m := range msgs {
3032- if m.Role != provider.RoleUser || isHiddenTranscriptMessage(m) || m.Meta[shellEscapeMetaKey] == "true" {3036+ if !isForkableUserMessage(m) {
3033 continue3037 continue
3034 }3038 }
3035 text := userMessageText(m)3039 text := userMessageText(m)
@@ -5793,6 +5797,32 @@ func (i *Interactive) clearPendingCompactTurnLocked() {
5793 i.pendingPostCompactNote = ""5797 i.pendingPostCompactNote = ""
5794 }5798 }
5795 5799
5800+const autoCompactContinuationPrompt = `Resume work on the user's most recent intent. Re-read the kept recent messages after the summary to confirm what the user asked for last. If their latest request supersedes earlier plans recorded in the summary, follow the latest request. If there is nothing left to do, say so briefly instead of inventing further work.`
5801+
5802+// startAutoCompactContinuation adds an internal user turn that tells the
5803+// model to resume the task after threshold compaction. Provider requests
5804+// cannot continue from an assistant tail on every supported API, so the
5805+// continuation must be represented as a user message. It is hidden from
5806+// the rendered transcript but remains in the persisted/model context.
5807+func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
5808+ i.mu.Lock()
5809+ ag := i.agent
5810+ i.mu.Unlock()
5811+ if ag == nil {
5812+ i.mu.Lock()
5813+ i.busy = false
5814+ i.mu.Unlock()
5815+ i.invalidate()
5816+ return
5817+ }
5818+ if ag.QueuedMessageCount() == 0 {
5819+ ag.AppendUserContext(autoCompactContinuationPrompt, map[string]string{
5820+ autoCompactContinueMetaKey: "true",
5821+ })
5822+ }
5823+ i.startTurnRequest(parent, "", nil, true, false)
5824+}
5825+
5796 // runCompact invokes core.Agent.Compact and reflects the progress in5826 // runCompact invokes core.Agent.Compact and reflects the progress in
5797 // the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c5827 // the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
5798 // cancel via the same cancelTurn channel used for normal turns.5828 // cancel via the same cancelTurn channel used for normal turns.
@@ -5831,7 +5861,18 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
5831 go func() {5861 go func() {
5832 // Sink discards deltas — we don't stream the summary to the UI.5862 // Sink discards deltas — we don't stream the summary to the UI.
5833 sink := func(delta string) {}5863 sink := func(delta string) {}
5834- summary, err := i.agent.Compact(ctx, 4, sink)5864+ msgsBefore := i.agent.Messages()
5865+ autoContinue := auto && shouldAutoContinueAfterCompaction(msgsBefore)
5866+ // Keep the usual recent tail when possible, but never let it cover
5867+ // the whole transcript. A short session can still be at 70–90% of
5868+ // a model's window because one prompt or tool result is large; the
5869+ // automatic path must summarize that session instead of failing
5870+ // before it reaches the compaction provider.
5871+ keepTail := 4
5872+ if n := len(msgsBefore); n > 0 && keepTail >= n {
5873+ keepTail = n - 1
5874+ }
5875+ summary, err := i.agent.Compact(ctx, keepTail, sink)
5835 _ = summary5876 _ = summary
5836 i.mu.Lock()5877 i.mu.Lock()
5837 i.busy = false5878 i.busy = false
@@ -5848,6 +5889,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
5848 var nextImages []provider.ImageBlock5889 var nextImages []provider.ImageBlock
5849 var hasNext bool5890 var hasNext bool
5850 var continueExisting bool5891 var continueExisting bool
5892+ var continueAutomatically bool
5851 5893
5852 switch {5894 switch {
5853 case err != nil && ctx.Err() != nil:5895 case err != nil && ctx.Err() != nil:
@@ -5907,20 +5949,35 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
5907 case len(i.queued) > 0:5949 case len(i.queued) > 0:
5908 next, i.queued = i.queued[0], i.queued[1:]5950 next, i.queued = i.queued[0], i.queued[1:]
5909 hasNext = true5951 hasNext = true
5952+ case autoContinue:
5953+ // A successful threshold compaction must hand control back to
5954+ // an unfinished model turn. Completed text answers should settle
5955+ // without an invented extra request; the user can still prompt
5956+ // again normally.
5957+ continueAutomatically = true
5910 }5958 }
5911 }5959 }
5960+ if hasNext || continueExisting || continueAutomatically {
5961+ // Keep the host busy while handing the next turn back to the
5962+ // agent. Prompts submitted in this hand-off window then enter
5963+ // the agent queue instead of racing a second startTurn call.
5964+ i.busy = true
5965+ }
5912 i.mu.Unlock()5966 i.mu.Unlock()
5913 i.invalidate()5967 i.invalidate()
5914 5968
5915- if hasNext || continueExisting {5969+ if hasNext || continueExisting || continueAutomatically {
5916 p := i.runCtx5970 p := i.runCtx
5917 if p == nil {5971 if p == nil {
5918 p = context.Background()5972 p = context.Background()
5919 }5973 }
5920- if continueExisting {5974+ switch {
5921- i.startTurnRequest(p, "", nil, true)5975+ case continueExisting:
5922- } else {5976+ i.startTurnRequest(p, "", nil, true, true)
5977+ case hasNext:
5923 i.startTurnWithImages(p, next, nextImages)5978 i.startTurnWithImages(p, next, nextImages)
5979+ case continueAutomatically:
5980+ i.startAutoCompactContinuation(p)
5924 }5981 }
5925 }5982 }
5926 }()5983 }()
@@ -6055,10 +6112,16 @@ func (i *Interactive) startTurn(parent context.Context, prompt string) {
6055 }6112 }
6056 6113
6057 func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {6114 func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {
6058- i.startTurnRequest(parent, prompt, images, false)6115+ i.startTurnRequest(parent, prompt, images, false, false)
6059 }6116 }
6060 6117
6061-func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, overflowRecoveryAttempted bool) {6118+// startTurnRequest starts a new prompt or continues an existing user turn.
6119+// continueExisting selects Agent.Continue so callers can retry a transcript
6120+// message without appending it twice. overflowRecoveryAttempted suppresses
6121+// the pre-turn threshold guard and the one-shot overflow recovery path; a
6122+// normal post-compaction continuation leaves it false so a still-too-large
6123+// rebuilt context can recover once more.
6124+func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, continueExisting, overflowRecoveryAttempted bool) {
6062 if i.agent == nil {6125 if i.agent == nil {
6063 // Text startup pre cannot run without credentials; continue so6126 // Text startup pre cannot run without credentials; continue so
6064 // deferred InitialInput (pre-fill or auto-submit) still applies.6127 // deferred InitialInput (pre-fill or auto-submit) still applies.
@@ -6148,7 +6211,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
6148 6211
6149 go func() {6212 go func() {
6150 var err error6213 var err error
6151- if overflowRecoveryAttempted {6214+ if continueExisting {
6152 err = i.agent.Continue(ctx, sink)6215 err = i.agent.Continue(ctx, sink)
6153 } else {6216 } else {
6154 err = i.agent.Prompt(ctx, prompt, images, sink)6217 err = i.agent.Prompt(ctx, prompt, images, sink)
@@ -6402,6 +6465,36 @@ func shouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool {
6402 return float64(inputTokens)/float64(contextWindow) >= float64(thresholdPercent)/1006465 return float64(inputTokens)/float64(contextWindow) >= float64(thresholdPercent)/100
6403 }6466 }
6404 6467
6468+// shouldAutoContinueAfterCompaction reports whether the transcript tail looks
6469+// unfinished. A complete assistant text answer should not cause an extra
6470+// model turn merely because its context was compacted; tool calls, reasoning-
6471+// only replies, and non-assistant tails still need the model to resume.
6472+func shouldAutoContinueAfterCompaction(msgs []provider.Message) bool {
6473+ if len(msgs) == 0 {
6474+ return false
6475+ }
6476+ last := msgs[len(msgs)-1]
6477+ if last.Role != provider.RoleAssistant {
6478+ return true
6479+ }
6480+ hasText := false
6481+ for _, content := range last.Content {
6482+ switch block := content.(type) {
6483+ case provider.ToolCallBlock:
6484+ return true
6485+ case provider.TextBlock:
6486+ if strings.TrimSpace(block.Text) != "" {
6487+ hasText = true
6488+ }
6489+ case provider.ReasoningBlock:
6490+ // Reasoning without a visible answer is not terminal.
6491+ default:
6492+ return true
6493+ }
6494+ }
6495+ return !hasText
6496+}
6497+
6405 // shouldAutoCompactLocked reports whether the last turn pushed context6498 // shouldAutoCompactLocked reports whether the last turn pushed context
6406 // usage past the auto-compact threshold. Must be called with i.mu6499 // usage past the auto-compact threshold. Must be called with i.mu
6407 // held; it reads lastCtxInput and the current model's context window.6500 // held; it reads lastCtxInput and the current model's context window.
@@ -7469,13 +7562,32 @@ func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo in
7469 7562
7470 // Read and validate the selected row before flushing or creating anything.7563 // Read and validate the selected row before flushing or creating anything.
7471 // This gives tool-call/result pairs an atomic boundary and prevents a7564 // This gives tool-call/result pairs an atomic boundary and prevents a
7472- // malformed selection from changing the active session.7565+ // malformed selection from changing the active session. Historical rows
7473- sess, msgs, err := core.OpenSession(src)7566+ // come from a pre-compaction segment; current rows use the effective
7474- if err != nil {7567+ // snapshot that the running agent resumes from.
7475- i.setSessionTreeError("tree: read selection: " + err.Error())7568+ var msgs []provider.Message
7476- return7569+ var historicalSegment core.SessionHistorySegment
7570+ if target.Historical {
7571+ history, err := core.ReadSessionHistory(src)
7572+ if err != nil {
7573+ i.setSessionTreeError("tree: read selection: " + err.Error())
7574+ return
7575+ }
7576+ if target.HistorySegment < 0 || target.HistorySegment >= len(history.Segments) {
7577+ i.setSessionTreeError("tree: selected history segment is unavailable")
7578+ return
7579+ }
7580+ historicalSegment = history.Segments[target.HistorySegment]
7581+ msgs = historicalSegment.Messages
7582+ } else {
7583+ sess, current, err := core.OpenSession(src)
7584+ if err != nil {
7585+ i.setSessionTreeError("tree: read selection: " + err.Error())
7586+ return
7587+ }
7588+ _ = sess.Close()
7589+ msgs = current
7477 }7590 }
7478- _ = sess.Close()
7479 selection, err := sessionTreeSelection(msgs, target)7591 selection, err := sessionTreeSelection(msgs, target)
7480 if err != nil {7592 if err != nil {
7481 i.setSessionTreeError("tree: " + err.Error())7593 i.setSessionTreeError("tree: " + err.Error())
@@ -7484,7 +7596,12 @@ func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo in
7484 if i.cfg.FlushSession != nil {7596 if i.cfg.FlushSession != nil {
7485 i.cfg.FlushSession()7597 i.cfg.FlushSession()
7486 }7598 }
7487- newPath, err := core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, selection.upTo)7599+ var newPath string
7600+ if target.Historical {
7601+ newPath, err = core.BranchSessionHiddenFromHistory(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, historicalSegment, selection.upTo)
7602+ } else {
7603+ newPath, err = core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, selection.upTo)
7604+ }
7488 if err != nil {7605 if err != nil {
7489 i.setSessionTreeError("tree: " + err.Error())7606 i.setSessionTreeError("tree: " + err.Error())
7490 return7607 return
M packages/agent/modes/jump_dialog.go +2 -2
@@ -244,7 +244,7 @@ func buildJumpTargets(msgs []provider.Message) []jumpTarget {
244244 var out []jumpTarget
245245 turn := 0
246246 for i, m := range msgs {
247- if m.Role != provider.RoleUser {
247+ if !isForkableUserMessage(m) {
248248 continue
249249 }
250250 turn++
@@ -286,7 +286,7 @@ func firstLineOfUserMessage(m provider.Message) string {
286286 func countToolsUntilNextUser(msgs []provider.Message, i int) int {
287287 n := 0
288288 for j := i + 1; j < len(msgs); j++ {
289- if msgs[j].Role == provider.RoleUser {
289+ if isForkableUserMessage(msgs[j]) {
290290 break
291291 }
292292 if msgs[j].Role == provider.RoleAssistant {
@@ -244,7 +244,7 @@ func buildJumpTargets(msgs []provider.Message) []jumpTarget {
244 var out []jumpTarget244 var out []jumpTarget
245 turn := 0245 turn := 0
246 for i, m := range msgs {246 for i, m := range msgs {
247- if m.Role != provider.RoleUser {247+ if !isForkableUserMessage(m) {
248 continue248 continue
249 }249 }
250 turn++250 turn++
@@ -286,7 +286,7 @@ func firstLineOfUserMessage(m provider.Message) string {
286 func countToolsUntilNextUser(msgs []provider.Message, i int) int {286 func countToolsUntilNextUser(msgs []provider.Message, i int) int {
287 n := 0287 n := 0
288 for j := i + 1; j < len(msgs); j++ {288 for j := i + 1; j < len(msgs); j++ {
289- if msgs[j].Role == provider.RoleUser {289+ if isForkableUserMessage(msgs[j]) {
290 break290 break
291 }291 }
292 if msgs[j].Role == provider.RoleAssistant {292 if msgs[j].Role == provider.RoleAssistant {
M packages/agent/modes/session_tree_dialog.go +126 -12
@@ -36,11 +36,11 @@ func (b sessionTreeBoundary) String() string {
3636 }
3737
3838 // sessionTreeTarget is the stable description of a row in the dialog.
39-// EffectiveIndex is an index into SourcePath's effective transcript (the
40-// snapshot returned by core.OpenSession), while SelectionBoundary is the
41-// message count to pass to core.BranchSessionHidden. Keeping both values is
42-// important for a user row: selecting it branches before that row so the
43-// complete draft can be put back in the editor.
39+// EffectiveIndex and SelectionBoundary refer to the row's provider-valid
40+// segment. Ordinary rows use SourcePath's effective transcript; historical
41+// rows use the pre-compaction segment identified by HistorySegment. Keeping
42+// both values is important for a user row: selecting it branches before that
43+// row so the complete draft can be put back in the editor.
4444 type sessionTreeTarget struct {
4545 SourcePath string
4646 EffectiveIndex int
@@ -48,6 +48,8 @@ type sessionTreeTarget struct {
4848 Role provider.Role
4949 UserDraft string
5050 Boundary sessionTreeBoundary
51+ Historical bool
52+ HistorySegment int
5153 }
5254
5355 func (t sessionTreeTarget) IsBoundary() bool {
@@ -112,6 +114,7 @@ type sessionTreeAction struct {
112114 type sessionTreeSnapshot struct {
113115 path string
114116 messages []provider.Message
117+ history []core.SessionHistorySegment
115118 }
116119
117120 const defaultSessionTreeRows = 12
@@ -296,7 +299,7 @@ func (d *sessionTreeDialog) Render(th tui.Theme, width int) []string {
296299 return lines
297300 }
298301 lines = append(lines, line(th.FG256(th.Muted,
299- "session branches (↑/↓, pgup/pgdn, home/end, enter checkout, esc cancel):")))
302+ "session history and branches (↑/↓, pgup/pgdn, home/end, enter checkout, esc cancel):")))
300303
301304 d.followCursor()
302305 start := d.viewTop
@@ -387,12 +390,16 @@ func preflightSessionTreeFamily(root *core.TreeNode) (map[string]sessionTreeSnap
387390 return nil
388391 }
389392 visiting[path] = true
390- snapshot, err := core.ReadSessionSnapshot(path)
393+ history, err := core.ReadSessionHistory(path)
391394 if err != nil {
392395 delete(visiting, path)
393396 return fmt.Errorf("session tree: read %q: %w", path, err)
394397 }
395- snapshots[path] = sessionTreeSnapshot{path: path, messages: snapshot.Messages}
398+ var messages []provider.Message
399+ if len(history.Segments) > 0 {
400+ messages = history.Segments[len(history.Segments)-1].Messages
401+ }
402+ snapshots[path] = sessionTreeSnapshot{path: path, messages: messages, history: history.Segments}
396403 for _, child := range node.Children {
397404 if err := walk(child); err != nil {
398405 delete(visiting, path)
@@ -478,7 +485,13 @@ func flattenSessionNode(node *core.TreeNode, currentPath string, snapshots map[s
478485 selectionBoundary = len(msgs)
479486 }
480487
481- all := buildSessionTreeItems(snapshot.path, msgs, depth, snapshot.path == currentPath)
488+ historyItems := buildSessionTreeHistoryItems(snapshot.path, snapshot.history, depth, snapshot.path == currentPath)
489+ allByIndex := make(map[int]sessionTreeItem, len(msgs))
490+ for _, item := range historyItems {
491+ if !item.target.Historical {
492+ allByIndex[item.target.EffectiveIndex] = item
493+ }
494+ }
482495 childrenAt := make(map[int][]*core.TreeNode)
483496 var stale []*core.TreeNode
484497 for _, child := range node.Children {
@@ -506,6 +519,11 @@ func flattenSessionNode(node *core.TreeNode, currentPath string, snapshots map[s
506519 }
507520
508521 var out []sessionTreeItem
522+ for _, item := range historyItems {
523+ if item.target.Historical {
524+ out = append(out, item)
525+ }
526+ }
509527 if boundary != sessionTreeMessageBoundary {
510528 turn := treeTurnAtBoundary(msgs, selectionBoundary)
511529 boundaryCurrent := snapshot.path == currentPath && (len(msgs) == 0 || boundary == sessionTreeEmptyBoundary)
@@ -522,8 +540,10 @@ func flattenSessionNode(node *core.TreeNode, currentPath string, snapshots map[s
522540 // children that forked in the hidden copied prefix are normalized to its
523541 // first visible index above, so they remain discoverable too.
524542 emitChildren(childrenAt[start], false)
525- for idx := start; idx < len(all); idx++ {
526- out = append(out, all[idx])
543+ for idx := start; idx < len(msgs); idx++ {
544+ if item, ok := allByIndex[idx]; ok {
545+ out = append(out, item)
546+ }
527547 emitChildren(childrenAt[idx+1], false)
528548 }
529549 // Stale children are attached after the current effective snapshot. Their
@@ -581,10 +601,32 @@ func treeTurnAtBoundary(msgs []provider.Message, boundary int) int {
581601 }
582602
583603 func buildSessionTreeItems(path string, msgs []provider.Message, depth int, currentPath bool) []sessionTreeItem {
604+ return buildSessionTreeSegmentItems(path, msgs, depth, currentPath, false, -1, nil)
605+}
606+
607+func buildSessionTreeHistoryItems(path string, segments []core.SessionHistorySegment, depth int, currentPath bool) []sessionTreeItem {
608+ var out []sessionTreeItem
609+ for segmentIdx, segment := range segments {
610+ historical := segmentIdx < len(segments)-1
611+ skip := compactionTailIndices(segments, segmentIdx)
612+ out = append(out, buildSessionTreeSegmentItems(path, segment.Messages, depth, currentPath && !historical, historical, segmentIdx, skip)...)
613+ }
614+ return out
615+}
616+
617+func buildSessionTreeSegmentItems(path string, msgs []provider.Message, depth int, currentPath, historical bool, historySegment int, skip map[int]bool) []sessionTreeItem {
584618 out := make([]sessionTreeItem, 0, len(msgs))
585619 turn := 0
586620 lastTurn := 0
621+ visible := make([]int, 0, len(msgs))
587622 for idx, msg := range msgs {
623+ if skip[idx] || !isForkableSessionTreeMessage(msg) {
624+ continue
625+ }
626+ visible = append(visible, idx)
627+ }
628+ for visibleIndex, idx := range visible {
629+ msg := msgs[idx]
588630 if msg.Role == provider.RoleUser {
589631 turn++
590632 lastTurn = turn
@@ -606,6 +648,8 @@ func buildSessionTreeItems(path string, msgs []provider.Message, depth int, curr
606648 Role: msg.Role,
607649 UserDraft: draft,
608650 Boundary: sessionTreeMessageBoundary,
651+ Historical: historical,
652+ HistorySegment: historySegment,
609653 }
610654 out = append(out, sessionTreeItem{
611655 label: fmt.Sprintf("%s: %s", roleLabel, preview),
@@ -615,13 +659,83 @@ func buildSessionTreeItems(path string, msgs []provider.Message, depth int, curr
615659 prompt: draft,
616660 path: path,
617661 depth: depth,
618- current: currentPath && idx == len(msgs)-1,
662+ current: currentPath && visibleIndex == len(visible)-1,
619663 target: target,
620664 })
621665 }
622666 return out
623667 }
624668
669+func compactionTailIndices(segments []core.SessionHistorySegment, segmentIdx int) map[int]bool {
670+ if segmentIdx <= 0 || segmentIdx >= len(segments) || !segments[segmentIdx].Compacted {
671+ return nil
672+ }
673+ current := segments[segmentIdx].Messages
674+ previous := segments[segmentIdx-1].Messages
675+ if len(current) < 2 || len(previous) == 0 || !isCompactionSummaryMessage(current[0]) && current[0].Meta["compaction"] != "true" {
676+ return nil
677+ }
678+ maxTail := len(current) - 1
679+ if len(previous) < maxTail {
680+ maxTail = len(previous)
681+ }
682+ for tailLen := maxTail; tailLen > 0; tailLen-- {
683+ if !reflect.DeepEqual(previous[len(previous)-tailLen:], current[1:1+tailLen]) {
684+ continue
685+ }
686+ skip := make(map[int]bool, tailLen)
687+ for idx := 1; idx <= tailLen; idx++ {
688+ skip[idx] = true
689+ }
690+ return skip
691+ }
692+ return nil
693+}
694+
695+func isForkableSessionTreeMessage(msg provider.Message) bool {
696+ if isHiddenTranscriptMessage(msg) || msg.Meta[shellEscapeMetaKey] == "true" || msg.Meta[autoCompactContinueMetaKey] == "true" || msg.Meta["compaction"] == "true" || isCompactionSummaryMessage(msg) {
697+ return false
698+ }
699+ switch msg.Role {
700+ case provider.RoleUser:
701+ return isForkableUserMessage(msg)
702+ case provider.RoleAssistant:
703+ if messageHasToolCall(msg) {
704+ return false
705+ }
706+ for _, content := range msg.Content {
707+ switch block := content.(type) {
708+ case provider.TextBlock:
709+ if strings.TrimSpace(block.Text) != "" {
710+ return true
711+ }
712+ case provider.ImageBlock:
713+ return true
714+ }
715+ }
716+ }
717+ return false
718+}
719+
720+func isForkableUserMessage(msg provider.Message) bool {
721+ if msg.Role != provider.RoleUser || isHiddenTranscriptMessage(msg) || msg.Meta[shellEscapeMetaKey] == "true" || msg.Meta[autoCompactContinueMetaKey] == "true" || msg.Meta["compaction"] == "true" || isCompactionSummaryMessage(msg) {
722+ return false
723+ }
724+ return sessionTreePreview(msg) != "(empty)"
725+}
726+
727+func isCompactionSummaryMessage(msg provider.Message) bool {
728+ if msg.Role != provider.RoleUser {
729+ return false
730+ }
731+ for _, content := range msg.Content {
732+ if block, ok := content.(provider.TextBlock); ok && strings.HasPrefix(strings.TrimSpace(block.Text), "## Context Summary (compacted)") {
733+ return true
734+ }
735+ }
736+ return false
737+}
738+
625739 func completeUserDraft(msg provider.Message) string {
626740 var text []string
627741 for _, c := range msg.Content {
@@ -36,11 +36,11 @@ func (b sessionTreeBoundary) String() string {
36 }36 }
37 37
38 // sessionTreeTarget is the stable description of a row in the dialog.38 // sessionTreeTarget is the stable description of a row in the dialog.
39-// EffectiveIndex is an index into SourcePath's effective transcript (the39+// EffectiveIndex and SelectionBoundary refer to the row's provider-valid
40-// snapshot returned by core.OpenSession), while SelectionBoundary is the40+// segment. Ordinary rows use SourcePath's effective transcript; historical
41-// message count to pass to core.BranchSessionHidden. Keeping both values is41+// rows use the pre-compaction segment identified by HistorySegment. Keeping
42-// important for a user row: selecting it branches before that row so the42+// both values is important for a user row: selecting it branches before that
43-// complete draft can be put back in the editor.43+// row so the complete draft can be put back in the editor.
44 type sessionTreeTarget struct {44 type sessionTreeTarget struct {
45 SourcePath string45 SourcePath string
46 EffectiveIndex int46 EffectiveIndex int
@@ -48,6 +48,8 @@ type sessionTreeTarget struct {
48 Role provider.Role48 Role provider.Role
49 UserDraft string49 UserDraft string
50 Boundary sessionTreeBoundary50 Boundary sessionTreeBoundary
51+ Historical bool
52+ HistorySegment int
51 }53 }
52 54
53 func (t sessionTreeTarget) IsBoundary() bool {55 func (t sessionTreeTarget) IsBoundary() bool {
@@ -112,6 +114,7 @@ type sessionTreeAction struct {
112 type sessionTreeSnapshot struct {114 type sessionTreeSnapshot struct {
113 path string115 path string
114 messages []provider.Message116 messages []provider.Message
117+ history []core.SessionHistorySegment
115 }118 }
116 119
117 const defaultSessionTreeRows = 12120 const defaultSessionTreeRows = 12
@@ -296,7 +299,7 @@ func (d *sessionTreeDialog) Render(th tui.Theme, width int) []string {
296 return lines299 return lines
297 }300 }
298 lines = append(lines, line(th.FG256(th.Muted,301 lines = append(lines, line(th.FG256(th.Muted,
299- "session branches (↑/↓, pgup/pgdn, home/end, enter checkout, esc cancel):")))302+ "session history and branches (↑/↓, pgup/pgdn, home/end, enter checkout, esc cancel):")))
300 303
301 d.followCursor()304 d.followCursor()
302 start := d.viewTop305 start := d.viewTop
@@ -387,12 +390,16 @@ func preflightSessionTreeFamily(root *core.TreeNode) (map[string]sessionTreeSnap
387 return nil390 return nil
388 }391 }
389 visiting[path] = true392 visiting[path] = true
390- snapshot, err := core.ReadSessionSnapshot(path)393+ history, err := core.ReadSessionHistory(path)
391 if err != nil {394 if err != nil {
392 delete(visiting, path)395 delete(visiting, path)
393 return fmt.Errorf("session tree: read %q: %w", path, err)396 return fmt.Errorf("session tree: read %q: %w", path, err)
394 }397 }
395- snapshots[path] = sessionTreeSnapshot{path: path, messages: snapshot.Messages}398+ var messages []provider.Message
399+ if len(history.Segments) > 0 {
400+ messages = history.Segments[len(history.Segments)-1].Messages
401+ }
402+ snapshots[path] = sessionTreeSnapshot{path: path, messages: messages, history: history.Segments}
396 for _, child := range node.Children {403 for _, child := range node.Children {
397 if err := walk(child); err != nil {404 if err := walk(child); err != nil {
398 delete(visiting, path)405 delete(visiting, path)
@@ -478,7 +485,13 @@ func flattenSessionNode(node *core.TreeNode, currentPath string, snapshots map[s
478 selectionBoundary = len(msgs)485 selectionBoundary = len(msgs)
479 }486 }
480 487
481- all := buildSessionTreeItems(snapshot.path, msgs, depth, snapshot.path == currentPath)488+ historyItems := buildSessionTreeHistoryItems(snapshot.path, snapshot.history, depth, snapshot.path == currentPath)
489+ allByIndex := make(map[int]sessionTreeItem, len(msgs))
490+ for _, item := range historyItems {
491+ if !item.target.Historical {
492+ allByIndex[item.target.EffectiveIndex] = item
493+ }
494+ }
482 childrenAt := make(map[int][]*core.TreeNode)495 childrenAt := make(map[int][]*core.TreeNode)
483 var stale []*core.TreeNode496 var stale []*core.TreeNode
484 for _, child := range node.Children {497 for _, child := range node.Children {
@@ -506,6 +519,11 @@ func flattenSessionNode(node *core.TreeNode, currentPath string, snapshots map[s
506 }519 }
507 520
508 var out []sessionTreeItem521 var out []sessionTreeItem
522+ for _, item := range historyItems {
523+ if item.target.Historical {
524+ out = append(out, item)
525+ }
526+ }
509 if boundary != sessionTreeMessageBoundary {527 if boundary != sessionTreeMessageBoundary {
510 turn := treeTurnAtBoundary(msgs, selectionBoundary)528 turn := treeTurnAtBoundary(msgs, selectionBoundary)
511 boundaryCurrent := snapshot.path == currentPath && (len(msgs) == 0 || boundary == sessionTreeEmptyBoundary)529 boundaryCurrent := snapshot.path == currentPath && (len(msgs) == 0 || boundary == sessionTreeEmptyBoundary)
@@ -522,8 +540,10 @@ func flattenSessionNode(node *core.TreeNode, currentPath string, snapshots map[s
522 // children that forked in the hidden copied prefix are normalized to its540 // children that forked in the hidden copied prefix are normalized to its
523 // first visible index above, so they remain discoverable too.541 // first visible index above, so they remain discoverable too.
524 emitChildren(childrenAt[start], false)542 emitChildren(childrenAt[start], false)
525- for idx := start; idx < len(all); idx++ {543+ for idx := start; idx < len(msgs); idx++ {
526- out = append(out, all[idx])544+ if item, ok := allByIndex[idx]; ok {
545+ out = append(out, item)
546+ }
527 emitChildren(childrenAt[idx+1], false)547 emitChildren(childrenAt[idx+1], false)
528 }548 }
529 // Stale children are attached after the current effective snapshot. Their549 // Stale children are attached after the current effective snapshot. Their
@@ -581,10 +601,32 @@ func treeTurnAtBoundary(msgs []provider.Message, boundary int) int {
581 }601 }
582 602
583 func buildSessionTreeItems(path string, msgs []provider.Message, depth int, currentPath bool) []sessionTreeItem {603 func buildSessionTreeItems(path string, msgs []provider.Message, depth int, currentPath bool) []sessionTreeItem {
604+ return buildSessionTreeSegmentItems(path, msgs, depth, currentPath, false, -1, nil)
605+}
606+
607+func buildSessionTreeHistoryItems(path string, segments []core.SessionHistorySegment, depth int, currentPath bool) []sessionTreeItem {
608+ var out []sessionTreeItem
609+ for segmentIdx, segment := range segments {
610+ historical := segmentIdx < len(segments)-1
611+ skip := compactionTailIndices(segments, segmentIdx)
612+ out = append(out, buildSessionTreeSegmentItems(path, segment.Messages, depth, currentPath && !historical, historical, segmentIdx, skip)...)
613+ }
614+ return out
615+}
616+
617+func buildSessionTreeSegmentItems(path string, msgs []provider.Message, depth int, currentPath, historical bool, historySegment int, skip map[int]bool) []sessionTreeItem {
584 out := make([]sessionTreeItem, 0, len(msgs))618 out := make([]sessionTreeItem, 0, len(msgs))
585 turn := 0619 turn := 0
586 lastTurn := 0620 lastTurn := 0
621+ visible := make([]int, 0, len(msgs))
587 for idx, msg := range msgs {622 for idx, msg := range msgs {
623+ if skip[idx] || !isForkableSessionTreeMessage(msg) {
624+ continue
625+ }
626+ visible = append(visible, idx)
627+ }
628+ for visibleIndex, idx := range visible {
629+ msg := msgs[idx]
588 if msg.Role == provider.RoleUser {630 if msg.Role == provider.RoleUser {
589 turn++631 turn++
590 lastTurn = turn632 lastTurn = turn
@@ -606,6 +648,8 @@ func buildSessionTreeItems(path string, msgs []provider.Message, depth int, curr
606 Role: msg.Role,648 Role: msg.Role,
607 UserDraft: draft,649 UserDraft: draft,
608 Boundary: sessionTreeMessageBoundary,650 Boundary: sessionTreeMessageBoundary,
651+ Historical: historical,
652+ HistorySegment: historySegment,
609 }653 }
610 out = append(out, sessionTreeItem{654 out = append(out, sessionTreeItem{
611 label: fmt.Sprintf("%s: %s", roleLabel, preview),655 label: fmt.Sprintf("%s: %s", roleLabel, preview),
@@ -615,13 +659,83 @@ func buildSessionTreeItems(path string, msgs []provider.Message, depth int, curr
615 prompt: draft,659 prompt: draft,
616 path: path,660 path: path,
617 depth: depth,661 depth: depth,
618- current: currentPath && idx == len(msgs)-1,662+ current: currentPath && visibleIndex == len(visible)-1,
619 target: target,663 target: target,
620 })664 })
621 }665 }
622 return out666 return out
623 }667 }
624 668
669+func compactionTailIndices(segments []core.SessionHistorySegment, segmentIdx int) map[int]bool {
670+ if segmentIdx <= 0 || segmentIdx >= len(segments) || !segments[segmentIdx].Compacted {
671+ return nil
672+ }
673+ current := segments[segmentIdx].Messages
674+ previous := segments[segmentIdx-1].Messages
675+ if len(current) < 2 || len(previous) == 0 || !isCompactionSummaryMessage(current[0]) && current[0].Meta["compaction"] != "true" {
676+ return nil
677+ }
678+ maxTail := len(current) - 1
679+ if len(previous) < maxTail {
680+ maxTail = len(previous)
681+ }
682+ for tailLen := maxTail; tailLen > 0; tailLen-- {
683+ if !reflect.DeepEqual(previous[len(previous)-tailLen:], current[1:1+tailLen]) {
684+ continue
685+ }
686+ skip := make(map[int]bool, tailLen)
687+ for idx := 1; idx <= tailLen; idx++ {
688+ skip[idx] = true
689+ }
690+ return skip
691+ }
692+ return nil
693+}
694+
695+func isForkableSessionTreeMessage(msg provider.Message) bool {
696+ if isHiddenTranscriptMessage(msg) || msg.Meta[shellEscapeMetaKey] == "true" || msg.Meta[autoCompactContinueMetaKey] == "true" || msg.Meta["compaction"] == "true" || isCompactionSummaryMessage(msg) {
697+ return false
698+ }
699+ switch msg.Role {
700+ case provider.RoleUser:
701+ return isForkableUserMessage(msg)
702+ case provider.RoleAssistant:
703+ if messageHasToolCall(msg) {
704+ return false
705+ }
706+ for _, content := range msg.Content {
707+ switch block := content.(type) {
708+ case provider.TextBlock:
709+ if strings.TrimSpace(block.Text) != "" {
710+ return true
711+ }
712+ case provider.ImageBlock:
713+ return true
714+ }
715+ }
716+ }
717+ return false
718+}
719+
720+func isForkableUserMessage(msg provider.Message) bool {
721+ if msg.Role != provider.RoleUser || isHiddenTranscriptMessage(msg) || msg.Meta[shellEscapeMetaKey] == "true" || msg.Meta[autoCompactContinueMetaKey] == "true" || msg.Meta["compaction"] == "true" || isCompactionSummaryMessage(msg) {
722+ return false
723+ }
724+ return sessionTreePreview(msg) != "(empty)"
725+}
726+
727+func isCompactionSummaryMessage(msg provider.Message) bool {
728+ if msg.Role != provider.RoleUser {
729+ return false
730+ }
731+ for _, content := range msg.Content {
732+ if block, ok := content.(provider.TextBlock); ok && strings.HasPrefix(strings.TrimSpace(block.Text), "## Context Summary (compacted)") {
733+ return true
734+ }
735+ }
736+ return false
737+}
738+
625 func completeUserDraft(msg provider.Message) string {739 func completeUserDraft(msg provider.Message) string {
626 var text []string740 var text []string
627 for _, c := range msg.Content {741 for _, c := range msg.Content {
M packages/agent/modes/session_tree_dialog_test.go +149 -0
@@ -46,6 +46,155 @@ func TestSessionTreeTargetCarriesEffectiveBoundaryAndDraft(t *testing.T) {
4646 }
4747 }
4848
49+func TestSessionTreeRetainsPreCompactionMessagesAndHidesToolRows(t *testing.T) {
50+ root := t.TempDir()
51+ cwd := "/workspace/tree-history"
52+ session, err := core.NewSession(root, cwd, "test", "model", "test")
53+ if err != nil {
54+ t.Fatal(err)
55+ }
56+ for _, message := range []provider.Message{
57+ treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "old prompt"}),
58+ treeTestMessage(provider.RoleAssistant, provider.TextBlock{Text: "old answer"}),
59+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "bash"}}},
60+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{CallID: "call-1", Content: []provider.Content{provider.TextBlock{Text: "tool output"}}}}},
61+ treeTestMessage(provider.RoleAssistant, provider.TextBlock{Text: "old final"}),
62+ } {
63+ if err := session.AppendMessage(message); err != nil {
64+ t.Fatal(err)
65+ }
66+ }
67+ if err := session.AppendCompaction([]provider.Message{
68+ {
69+ Role: provider.RoleUser,
70+ Meta: map[string]string{"compaction": "true"},
71+ Content: []provider.Content{provider.TextBlock{Text: "summary"}},
72+ },
73+ treeTestMessage(provider.RoleAssistant, provider.TextBlock{Text: "old final"}),
74+ }); err != nil {
75+ t.Fatal(err)
76+ }
77+ if err := session.AppendMessage(treeTestMessage(provider.RoleAssistant, provider.TextBlock{Text: "new answer"})); err != nil {
78+ t.Fatal(err)
79+ }
80+ if err := session.AppendMessage(provider.Message{
81+ Role: provider.RoleUser,
82+ Meta: map[string]string{shellEscapeMetaKey: "true"},
83+ Content: []provider.Content{provider.TextBlock{Text: "shell output"}},
84+ }); err != nil {
85+ t.Fatal(err)
86+ }
87+ path := session.Path
88+ if err := session.Close(); err != nil {
89+ t.Fatal(err)
90+ }
91+
92+ d := newSessionTreeDialog()
93+ if !d.OpenSessionFamily(root, cwd, path) {
94+ t.Fatal("OpenSessionFamily returned false")
95+ }
96+ var oldPrompt, newAnswer *sessionTreeItem
97+ oldFinalRows := 0
98+ for idx := range d.items {
99+ item := &d.items[idx]
100+ if strings.Contains(item.label, "old prompt") {
101+ oldPrompt = item
102+ }
103+ if strings.Contains(item.label, "new answer") {
104+ newAnswer = item
105+ }
106+ if strings.Contains(item.label, "old final") {
107+ oldFinalRows++
108+ }
109+ if strings.Contains(item.label, "tool") || strings.Contains(item.label, "result") || strings.Contains(item.label, "summary") || strings.Contains(item.label, "shell output") {
110+ t.Fatalf("internal/tool row leaked into fork picker: %q", item.label)
111+ }
112+ }
113+ if oldPrompt == nil || !oldPrompt.target.Historical {
114+ t.Fatalf("old pre-compaction prompt target = %+v, want historical target", oldPrompt)
115+ }
116+ if newAnswer == nil || newAnswer.target.Historical {
117+ t.Fatalf("current answer target = %+v, want effective target", newAnswer)
118+ }
119+ if oldFinalRows != 1 {
120+ t.Fatalf("preserved compaction-tail row rendered %d times, want once", oldFinalRows)
121+ }
122+}
123+
124+func TestSessionTreeHistoricalSelectionCreatesBranchFromOldSegment(t *testing.T) {
125+ root := t.TempDir()
126+ cwd := "/workspace/tree-history-branch"
127+ session, err := core.NewSession(root, cwd, "test", "model", "test")
128+ if err != nil {
129+ t.Fatal(err)
130+ }
131+ if err := session.AppendMessage(treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "old prompt"})); err != nil {
132+ t.Fatal(err)
133+ }
134+ if err := session.AppendMessage(treeTestMessage(provider.RoleAssistant, provider.TextBlock{Text: "old answer"})); err != nil {
135+ t.Fatal(err)
136+ }
137+ if err := session.AppendCompaction([]provider.Message{{
138+ Role: provider.RoleUser,
139+ Meta: map[string]string{"compaction": "true"},
140+ Content: []provider.Content{provider.TextBlock{Text: "summary"}},
141+ }}); err != nil {
142+ t.Fatal(err)
143+ }
144+ if err := session.AppendMessage(treeTestMessage(provider.RoleAssistant, provider.TextBlock{Text: "new answer"})); err != nil {
145+ t.Fatal(err)
146+ }
147+ path := session.Path
148+ if err := session.Close(); err != nil {
149+ t.Fatal(err)
150+ }
151+
152+ d := newSessionTreeDialog()
153+ if !d.OpenSessionFamily(root, cwd, path) {
154+ t.Fatal("OpenSessionFamily returned false")
155+ }
156+ var target sessionTreeTarget
157+ found := false
158+ for _, item := range d.items {
159+ if strings.Contains(item.label, "old answer") {
160+ target = item.target
161+ found = true
162+ break
163+ }
164+ }
165+ if !found || !target.Historical {
166+ t.Fatalf("old target = %+v, found=%v", target, found)
167+ }
168+
169+ ag := core.NewAgent(nil, "model", "", nil)
170+ var loaded string
171+ i := NewInteractive(InteractiveConfig{
172+ Agent: ag,
173+ CWD: cwd,
174+ SessionsRoot: root,
175+ Version: "test",
176+ CurrentSessionPath: func() string { return path },
177+ LoadSession: func(newPath string) error {
178+ loaded = newPath
179+ return nil
180+ },
181+ })
182+ i.applySessionTreeTarget(target, 1)
183+ if loaded == "" {
184+ t.Fatalf("historical selection did not load a branch; status=%q", i.statusErr)
185+ }
186+ branch, messages, err := core.OpenSession(loaded)
187+ if err != nil {
188+ t.Fatal(err)
189+ }
190+ if err := branch.Close(); err != nil {
191+ t.Fatal(err)
192+ }
193+ if len(messages) != 2 || userMessageText(messages[0]) != "old prompt" || userMessageText(messages[1]) != "old answer" {
194+ t.Fatalf("historical branch messages = %+v, want old prompt and answer", messages)
195+ }
196+}
197+
49198 func TestSessionTreeUsesCurrentFamilyAndNoRootFallback(t *testing.T) {
50199 root := t.TempDir()
51200 cwd := "/workspace/current-family"
@@ -46,6 +46,155 @@ func TestSessionTreeTargetCarriesEffectiveBoundaryAndDraft(t *testing.T) {
46 }46 }
47 }47 }
48 48
49+func TestSessionTreeRetainsPreCompactionMessagesAndHidesToolRows(t *testing.T) {
50+ root := t.TempDir()
51+ cwd := "/workspace/tree-history"
52+ session, err := core.NewSession(root, cwd, "test", "model", "test")
53+ if err != nil {
54+ t.Fatal(err)
55+ }
56+ for _, message := range []provider.Message{
57+ treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "old prompt"}),
58+ treeTestMessage(provider.RoleAssistant, provider.TextBlock{Text: "old answer"}),
59+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "bash"}}},
60+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{CallID: "call-1", Content: []provider.Content{provider.TextBlock{Text: "tool output"}}}}},
61+ treeTestMessage(provider.RoleAssistant, provider.TextBlock{Text: "old final"}),
62+ } {
63+ if err := session.AppendMessage(message); err != nil {
64+ t.Fatal(err)
65+ }
66+ }
67+ if err := session.AppendCompaction([]provider.Message{
68+ {
69+ Role: provider.RoleUser,
70+ Meta: map[string]string{"compaction": "true"},
71+ Content: []provider.Content{provider.TextBlock{Text: "summary"}},
72+ },
73+ treeTestMessage(provider.RoleAssistant, provider.TextBlock{Text: "old final"}),
74+ }); err != nil {
75+ t.Fatal(err)
76+ }
77+ if err := session.AppendMessage(treeTestMessage(provider.RoleAssistant, provider.TextBlock{Text: "new answer"})); err != nil {
78+ t.Fatal(err)
79+ }
80+ if err := session.AppendMessage(provider.Message{
81+ Role: provider.RoleUser,
82+ Meta: map[string]string{shellEscapeMetaKey: "true"},
83+ Content: []provider.Content{provider.TextBlock{Text: "shell output"}},
84+ }); err != nil {
85+ t.Fatal(err)
86+ }
87+ path := session.Path
88+ if err := session.Close(); err != nil {
89+ t.Fatal(err)
90+ }
91+
92+ d := newSessionTreeDialog()
93+ if !d.OpenSessionFamily(root, cwd, path) {
94+ t.Fatal("OpenSessionFamily returned false")
95+ }
96+ var oldPrompt, newAnswer *sessionTreeItem
97+ oldFinalRows := 0
98+ for idx := range d.items {
99+ item := &d.items[idx]
100+ if strings.Contains(item.label, "old prompt") {
101+ oldPrompt = item
102+ }
103+ if strings.Contains(item.label, "new answer") {
104+ newAnswer = item
105+ }
106+ if strings.Contains(item.label, "old final") {
107+ oldFinalRows++
108+ }
109+ if strings.Contains(item.label, "tool") || strings.Contains(item.label, "result") || strings.Contains(item.label, "summary") || strings.Contains(item.label, "shell output") {
110+ t.Fatalf("internal/tool row leaked into fork picker: %q", item.label)
111+ }
112+ }
113+ if oldPrompt == nil || !oldPrompt.target.Historical {
114+ t.Fatalf("old pre-compaction prompt target = %+v, want historical target", oldPrompt)
115+ }
116+ if newAnswer == nil || newAnswer.target.Historical {
117+ t.Fatalf("current answer target = %+v, want effective target", newAnswer)
118+ }
119+ if oldFinalRows != 1 {
120+ t.Fatalf("preserved compaction-tail row rendered %d times, want once", oldFinalRows)
121+ }
122+}
123+
124+func TestSessionTreeHistoricalSelectionCreatesBranchFromOldSegment(t *testing.T) {
125+ root := t.TempDir()
126+ cwd := "/workspace/tree-history-branch"
127+ session, err := core.NewSession(root, cwd, "test", "model", "test")
128+ if err != nil {
129+ t.Fatal(err)
130+ }
131+ if err := session.AppendMessage(treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "old prompt"})); err != nil {
132+ t.Fatal(err)
133+ }
134+ if err := session.AppendMessage(treeTestMessage(provider.RoleAssistant, provider.TextBlock{Text: "old answer"})); err != nil {
135+ t.Fatal(err)
136+ }
137+ if err := session.AppendCompaction([]provider.Message{{
138+ Role: provider.RoleUser,
139+ Meta: map[string]string{"compaction": "true"},
140+ Content: []provider.Content{provider.TextBlock{Text: "summary"}},
141+ }}); err != nil {
142+ t.Fatal(err)
143+ }
144+ if err := session.AppendMessage(treeTestMessage(provider.RoleAssistant, provider.TextBlock{Text: "new answer"})); err != nil {
145+ t.Fatal(err)
146+ }
147+ path := session.Path
148+ if err := session.Close(); err != nil {
149+ t.Fatal(err)
150+ }
151+
152+ d := newSessionTreeDialog()
153+ if !d.OpenSessionFamily(root, cwd, path) {
154+ t.Fatal("OpenSessionFamily returned false")
155+ }
156+ var target sessionTreeTarget
157+ found := false
158+ for _, item := range d.items {
159+ if strings.Contains(item.label, "old answer") {
160+ target = item.target
161+ found = true
162+ break
163+ }
164+ }
165+ if !found || !target.Historical {
166+ t.Fatalf("old target = %+v, found=%v", target, found)
167+ }
168+
169+ ag := core.NewAgent(nil, "model", "", nil)
170+ var loaded string
171+ i := NewInteractive(InteractiveConfig{
172+ Agent: ag,
173+ CWD: cwd,
174+ SessionsRoot: root,
175+ Version: "test",
176+ CurrentSessionPath: func() string { return path },
177+ LoadSession: func(newPath string) error {
178+ loaded = newPath
179+ return nil
180+ },
181+ })
182+ i.applySessionTreeTarget(target, 1)
183+ if loaded == "" {
184+ t.Fatalf("historical selection did not load a branch; status=%q", i.statusErr)
185+ }
186+ branch, messages, err := core.OpenSession(loaded)
187+ if err != nil {
188+ t.Fatal(err)
189+ }
190+ if err := branch.Close(); err != nil {
191+ t.Fatal(err)
192+ }
193+ if len(messages) != 2 || userMessageText(messages[0]) != "old prompt" || userMessageText(messages[1]) != "old answer" {
194+ t.Fatalf("historical branch messages = %+v, want old prompt and answer", messages)
195+ }
196+}
197+
49 func TestSessionTreeUsesCurrentFamilyAndNoRootFallback(t *testing.T) {198 func TestSessionTreeUsesCurrentFamilyAndNoRootFallback(t *testing.T) {
50 root := t.TempDir()199 root := t.TempDir()
51 cwd := "/workspace/current-family"200 cwd := "/workspace/current-family"
M packages/core/session.go +159 -7
@@ -91,8 +91,9 @@ type sessionLineHead struct {
9191 // row was written. MessageCount uses the effective transcript (after the
9292 // latest compaction and tool-pair repair), not the number of raw JSONL rows.
9393 type SessionUsageCheckpoint struct {
94- MessageCount int
95- Cumulative provider.Usage
94+ MessageCount int
95+ Cumulative provider.Usage
96+ CompactionGeneration int
9697 }
9798
9899 // SessionSnapshot is the effective, in-memory view of a session file.
@@ -100,12 +101,31 @@ type SessionUsageCheckpoint struct {
100101 // JSONL audit history is deliberately not exposed here because message
101102 // indices used by tree navigation and branching must agree.
102103 type SessionSnapshot struct {
103- Meta SessionMeta
104- Title string
104+ Meta SessionMeta
105+ Title string
106+ Messages []provider.Message
107+ UsageCheckpoints []SessionUsageCheckpoint
108+ CompactionGeneration int
109+}
110+
111+// SessionHistorySegment is one provider-valid transcript era. A compaction
112+// starts a new segment containing the replacement summary and preserved tail;
113+// earlier segments remain available for session-tree checkout without being
114+// used to resume the live agent.
115+type SessionHistorySegment struct {
116+ Compacted bool
105117 Messages []provider.Message
106118 UsageCheckpoints []SessionUsageCheckpoint
107119 }
108120
121+// SessionHistory contains every persisted transcript era in chronological
122+// order. Unlike SessionSnapshot, it intentionally retains segments replaced by
123+// compaction so navigation can fork from older user and assistant messages.
124+type SessionHistory struct {
125+ Meta SessionMeta
126+ Segments []SessionHistorySegment
127+}
128+
109129 // SessionsDir returns the per-cwd sessions directory under root.
110130 func SessionsDir(root, cwd string) string {
111131 sum := sha256.Sum256([]byte(cwd))
@@ -204,7 +224,14 @@ func SessionUsageDetail(path string) (cumulative, lastTurn provider.Usage, err e
204224 if len(snapshot.UsageCheckpoints) == 0 {
205225 return provider.Usage{}, provider.Usage{}, nil
206226 }
207- last := snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-1].Cumulative
227+ latestCheckpoint := snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-1]
228+ if latestCheckpoint.CompactionGeneration < snapshot.CompactionGeneration {
229+ // The latest usage row belongs to the pre-compaction context. Keep
230+ // cumulative cost and totals, but reset the live context gauge until
231+ // a usage row from the rebuilt transcript is persisted.
232+ return latestCheckpoint.Cumulative, provider.Usage{}, nil
233+ }
234+ last := latestCheckpoint.Cumulative
208235 var prev provider.Usage
209236 if len(snapshot.UsageCheckpoints) > 1 {
210237 prev = snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-2].Cumulative
@@ -335,6 +362,8 @@ func ReadSessionSnapshot(path string) (SessionSnapshot, error) {
335362 return SessionSnapshot{}, fmt.Errorf("session snapshot %q: file is empty", path)
336363 }
337364
365+ snapshot.CompactionGeneration = generation
366+
338367 // Repair only after compaction replacement. This makes synthetic tool
339368 // results and the removal of orphaned results part of the same indexed
340369 // stream that OpenSession returns and that BranchSession materializes.
@@ -353,13 +382,136 @@ func ReadSessionSnapshot(path string) (SessionSnapshot, error) {
353382 count = len(rawMessages)
354383 }
355384 snapshot.UsageCheckpoints = append(snapshot.UsageCheckpoints, SessionUsageCheckpoint{
356- MessageCount: count,
357- Cumulative: checkpoint.cumulative,
385+ MessageCount: count,
386+ Cumulative: checkpoint.cumulative,
387+ CompactionGeneration: checkpoint.generation,
358388 })
359389 }
360390 return snapshot, nil
361391 }
362392
393+// ReadSessionHistory reads every provider-valid transcript era in a session.
394+// The effective snapshot remains the source of truth for resume and ordinary
395+// branching; this audit projection exists for session-tree navigation so a
396+// compaction cannot make earlier fork points disappear.
397+func ReadSessionHistory(path string) (SessionHistory, error) {
398+ f, err := os.Open(path)
399+ if err != nil {
400+ return SessionHistory{}, fmt.Errorf("session history: open %q: %w", path, err)
401+ }
402+ defer f.Close()
403+
404+ var history SessionHistory
405+ var sawMeta bool
406+ type rawCheckpoint struct {
407+ messageCount int
408+ cumulative provider.Usage
409+ }
410+ type rawSegment struct {
411+ compacted bool
412+ messages []provider.Message
413+ checkpoints []rawCheckpoint
414+ }
415+ current := rawSegment{}
416+
417+ appendCurrent := func() {
418+ repaired := repairSessionMessages(current.messages)
419+ segment := SessionHistorySegment{
420+ Compacted: current.compacted,
421+ Messages: repaired.messages,
422+ }
423+ for _, checkpoint := range current.checkpoints {
424+ segment.UsageCheckpoints = append(segment.UsageCheckpoints, SessionUsageCheckpoint{
425+ MessageCount: repaired.prefixCount(checkpoint.messageCount),
426+ Cumulative: checkpoint.cumulative,
427+ })
428+ }
429+ if segment.Compacted || len(segment.Messages) > 0 || len(segment.UsageCheckpoints) > 0 {
430+ history.Segments = append(history.Segments, segment)
431+ }
432+ current = rawSegment{}
433+ }
434+
435+ err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error {
436+ var head sessionLineHead
437+ if err := json.Unmarshal(line, &head); err != nil {
438+ return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err)
439+ }
440+ if head.Type == "" {
441+ return fmt.Errorf("line %d: missing row type", lineNo)
442+ }
443+ if !sawMeta && head.Type != "meta" {
444+ return fmt.Errorf("line %d: first row is not meta", lineNo)
445+ }
446+
447+ switch head.Type {
448+ case "meta":
449+ var row sessionLine
450+ if err := json.Unmarshal(line, &row); err != nil {
451+ return fmt.Errorf("line %d: invalid meta row: %w", lineNo, err)
452+ }
453+ if row.Meta == nil || row.Meta.ID == "" {
454+ return fmt.Errorf("line %d: meta row has no session id", lineNo)
455+ }
456+ history.Meta = *row.Meta
457+ sawMeta = true
458+
459+ case "message":
460+ message, err := hydrateMessage(line)
461+ if err != nil {
462+ return fmt.Errorf("line %d: invalid message row: %w", lineNo, err)
463+ }
464+ current.messages = append(current.messages, message)
465+
466+ case "compaction":
467+ compacted, err := hydrateCompaction(line)
468+ if err != nil {
469+ return fmt.Errorf("line %d: invalid compaction row: %w", lineNo, err)
470+ }
471+ appendCurrent()
472+ current = rawSegment{compacted: true, messages: compacted}
473+
474+ case "usage":
475+ var row struct {
476+ Cumulative *provider.Usage `json:"cumulative"`
477+ }
478+ if err := json.Unmarshal(line, &row); err != nil {
479+ return fmt.Errorf("line %d: invalid usage row: %w", lineNo, err)
480+ }
481+ if row.Cumulative == nil {
482+ return fmt.Errorf("line %d: usage row has no cumulative usage", lineNo)
483+ }
484+ current.checkpoints = append(current.checkpoints, rawCheckpoint{
485+ messageCount: len(current.messages),
486+ cumulative: *row.Cumulative,
487+ })
488+
489+ case "rename":
490+ var row struct {
491+ Title *string `json:"title"`
492+ }
493+ if err := json.Unmarshal(line, &row); err != nil {
494+ return fmt.Errorf("line %d: invalid rename row: %w", lineNo, err)
495+ }
496+ if row.Title == nil {
497+ return fmt.Errorf("line %d: rename row has no title", lineNo)
498+ }
499+
500+ default:
501+ return fmt.Errorf("line %d: unknown row type %q", lineNo, head.Type)
502+ }
503+ return nil
504+ })
505+ if err != nil {
506+ return SessionHistory{}, fmt.Errorf("session history %q: %w", path, err)
507+ }
508+ if !sawMeta {
509+ return SessionHistory{}, fmt.Errorf("session history %q: file is empty", path)
510+ }
511+ appendCurrent()
512+ return history, nil
513+}
514+
363515 // forEachStrictJSONLLine is the complete-read counterpart to
364516 // forEachJSONLLine. It rejects malformed and blank rows and reports the
365517 // line number so callers cannot accidentally proceed with a partial file.
@@ -91,8 +91,9 @@ type sessionLineHead struct {
91 // row was written. MessageCount uses the effective transcript (after the91 // row was written. MessageCount uses the effective transcript (after the
92 // latest compaction and tool-pair repair), not the number of raw JSONL rows.92 // latest compaction and tool-pair repair), not the number of raw JSONL rows.
93 type SessionUsageCheckpoint struct {93 type SessionUsageCheckpoint struct {
94- MessageCount int94+ MessageCount int
95- Cumulative provider.Usage95+ Cumulative provider.Usage
96+ CompactionGeneration int
96 }97 }
97 98
98 // SessionSnapshot is the effective, in-memory view of a session file.99 // SessionSnapshot is the effective, in-memory view of a session file.
@@ -100,12 +101,31 @@ type SessionUsageCheckpoint struct {
100 // JSONL audit history is deliberately not exposed here because message101 // JSONL audit history is deliberately not exposed here because message
101 // indices used by tree navigation and branching must agree.102 // indices used by tree navigation and branching must agree.
102 type SessionSnapshot struct {103 type SessionSnapshot struct {
103- Meta SessionMeta104+ Meta SessionMeta
104- Title string105+ Title string
106+ Messages []provider.Message
107+ UsageCheckpoints []SessionUsageCheckpoint
108+ CompactionGeneration int
109+}
110+
111+// SessionHistorySegment is one provider-valid transcript era. A compaction
112+// starts a new segment containing the replacement summary and preserved tail;
113+// earlier segments remain available for session-tree checkout without being
114+// used to resume the live agent.
115+type SessionHistorySegment struct {
116+ Compacted bool
105 Messages []provider.Message117 Messages []provider.Message
106 UsageCheckpoints []SessionUsageCheckpoint118 UsageCheckpoints []SessionUsageCheckpoint
107 }119 }
108 120
121+// SessionHistory contains every persisted transcript era in chronological
122+// order. Unlike SessionSnapshot, it intentionally retains segments replaced by
123+// compaction so navigation can fork from older user and assistant messages.
124+type SessionHistory struct {
125+ Meta SessionMeta
126+ Segments []SessionHistorySegment
127+}
128+
109 // SessionsDir returns the per-cwd sessions directory under root.129 // SessionsDir returns the per-cwd sessions directory under root.
110 func SessionsDir(root, cwd string) string {130 func SessionsDir(root, cwd string) string {
111 sum := sha256.Sum256([]byte(cwd))131 sum := sha256.Sum256([]byte(cwd))
@@ -204,7 +224,14 @@ func SessionUsageDetail(path string) (cumulative, lastTurn provider.Usage, err e
204 if len(snapshot.UsageCheckpoints) == 0 {224 if len(snapshot.UsageCheckpoints) == 0 {
205 return provider.Usage{}, provider.Usage{}, nil225 return provider.Usage{}, provider.Usage{}, nil
206 }226 }
207- last := snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-1].Cumulative227+ latestCheckpoint := snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-1]
228+ if latestCheckpoint.CompactionGeneration < snapshot.CompactionGeneration {
229+ // The latest usage row belongs to the pre-compaction context. Keep
230+ // cumulative cost and totals, but reset the live context gauge until
231+ // a usage row from the rebuilt transcript is persisted.
232+ return latestCheckpoint.Cumulative, provider.Usage{}, nil
233+ }
234+ last := latestCheckpoint.Cumulative
208 var prev provider.Usage235 var prev provider.Usage
209 if len(snapshot.UsageCheckpoints) > 1 {236 if len(snapshot.UsageCheckpoints) > 1 {
210 prev = snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-2].Cumulative237 prev = snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-2].Cumulative
@@ -335,6 +362,8 @@ func ReadSessionSnapshot(path string) (SessionSnapshot, error) {
335 return SessionSnapshot{}, fmt.Errorf("session snapshot %q: file is empty", path)362 return SessionSnapshot{}, fmt.Errorf("session snapshot %q: file is empty", path)
336 }363 }
337 364
365+ snapshot.CompactionGeneration = generation
366+
338 // Repair only after compaction replacement. This makes synthetic tool367 // Repair only after compaction replacement. This makes synthetic tool
339 // results and the removal of orphaned results part of the same indexed368 // results and the removal of orphaned results part of the same indexed
340 // stream that OpenSession returns and that BranchSession materializes.369 // stream that OpenSession returns and that BranchSession materializes.
@@ -353,13 +382,136 @@ func ReadSessionSnapshot(path string) (SessionSnapshot, error) {
353 count = len(rawMessages)382 count = len(rawMessages)
354 }383 }
355 snapshot.UsageCheckpoints = append(snapshot.UsageCheckpoints, SessionUsageCheckpoint{384 snapshot.UsageCheckpoints = append(snapshot.UsageCheckpoints, SessionUsageCheckpoint{
356- MessageCount: count,385+ MessageCount: count,
357- Cumulative: checkpoint.cumulative,386+ Cumulative: checkpoint.cumulative,
387+ CompactionGeneration: checkpoint.generation,
358 })388 })
359 }389 }
360 return snapshot, nil390 return snapshot, nil
361 }391 }
362 392
393+// ReadSessionHistory reads every provider-valid transcript era in a session.
394+// The effective snapshot remains the source of truth for resume and ordinary
395+// branching; this audit projection exists for session-tree navigation so a
396+// compaction cannot make earlier fork points disappear.
397+func ReadSessionHistory(path string) (SessionHistory, error) {
398+ f, err := os.Open(path)
399+ if err != nil {
400+ return SessionHistory{}, fmt.Errorf("session history: open %q: %w", path, err)
401+ }
402+ defer f.Close()
403+
404+ var history SessionHistory
405+ var sawMeta bool
406+ type rawCheckpoint struct {
407+ messageCount int
408+ cumulative provider.Usage
409+ }
410+ type rawSegment struct {
411+ compacted bool
412+ messages []provider.Message
413+ checkpoints []rawCheckpoint
414+ }
415+ current := rawSegment{}
416+
417+ appendCurrent := func() {
418+ repaired := repairSessionMessages(current.messages)
419+ segment := SessionHistorySegment{
420+ Compacted: current.compacted,
421+ Messages: repaired.messages,
422+ }
423+ for _, checkpoint := range current.checkpoints {
424+ segment.UsageCheckpoints = append(segment.UsageCheckpoints, SessionUsageCheckpoint{
425+ MessageCount: repaired.prefixCount(checkpoint.messageCount),
426+ Cumulative: checkpoint.cumulative,
427+ })
428+ }
429+ if segment.Compacted || len(segment.Messages) > 0 || len(segment.UsageCheckpoints) > 0 {
430+ history.Segments = append(history.Segments, segment)
431+ }
432+ current = rawSegment{}
433+ }
434+
435+ err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error {
436+ var head sessionLineHead
437+ if err := json.Unmarshal(line, &head); err != nil {
438+ return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err)
439+ }
440+ if head.Type == "" {
441+ return fmt.Errorf("line %d: missing row type", lineNo)
442+ }
443+ if !sawMeta && head.Type != "meta" {
444+ return fmt.Errorf("line %d: first row is not meta", lineNo)
445+ }
446+
447+ switch head.Type {
448+ case "meta":
449+ var row sessionLine
450+ if err := json.Unmarshal(line, &row); err != nil {
451+ return fmt.Errorf("line %d: invalid meta row: %w", lineNo, err)
452+ }
453+ if row.Meta == nil || row.Meta.ID == "" {
454+ return fmt.Errorf("line %d: meta row has no session id", lineNo)
455+ }
456+ history.Meta = *row.Meta
457+ sawMeta = true
458+
459+ case "message":
460+ message, err := hydrateMessage(line)
461+ if err != nil {
462+ return fmt.Errorf("line %d: invalid message row: %w", lineNo, err)
463+ }
464+ current.messages = append(current.messages, message)
465+
466+ case "compaction":
467+ compacted, err := hydrateCompaction(line)
468+ if err != nil {
469+ return fmt.Errorf("line %d: invalid compaction row: %w", lineNo, err)
470+ }
471+ appendCurrent()
472+ current = rawSegment{compacted: true, messages: compacted}
473+
474+ case "usage":
475+ var row struct {
476+ Cumulative *provider.Usage `json:"cumulative"`
477+ }
478+ if err := json.Unmarshal(line, &row); err != nil {
479+ return fmt.Errorf("line %d: invalid usage row: %w", lineNo, err)
480+ }
481+ if row.Cumulative == nil {
482+ return fmt.Errorf("line %d: usage row has no cumulative usage", lineNo)
483+ }
484+ current.checkpoints = append(current.checkpoints, rawCheckpoint{
485+ messageCount: len(current.messages),
486+ cumulative: *row.Cumulative,
487+ })
488+
489+ case "rename":
490+ var row struct {
491+ Title *string `json:"title"`
492+ }
493+ if err := json.Unmarshal(line, &row); err != nil {
494+ return fmt.Errorf("line %d: invalid rename row: %w", lineNo, err)
495+ }
496+ if row.Title == nil {
497+ return fmt.Errorf("line %d: rename row has no title", lineNo)
498+ }
499+
500+ default:
501+ return fmt.Errorf("line %d: unknown row type %q", lineNo, head.Type)
502+ }
503+ return nil
504+ })
505+ if err != nil {
506+ return SessionHistory{}, fmt.Errorf("session history %q: %w", path, err)
507+ }
508+ if !sawMeta {
509+ return SessionHistory{}, fmt.Errorf("session history %q: file is empty", path)
510+ }
511+ appendCurrent()
512+ return history, nil
513+}
514+
363 // forEachStrictJSONLLine is the complete-read counterpart to515 // forEachStrictJSONLLine is the complete-read counterpart to
364 // forEachJSONLLine. It rejects malformed and blank rows and reports the516 // forEachJSONLLine. It rejects malformed and blank rows and reports the
365 // line number so callers cannot accidentally proceed with a partial file.517 // line number so callers cannot accidentally proceed with a partial file.
A packages/core/session_history_test.go +122 -0
@@ -0,0 +1,122 @@
1+package core
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/patriceckhart/zot/packages/provider"
8+)
9+
10+func TestReadSessionHistoryRetainsPreCompactionSegments(t *testing.T) {
11+ root := t.TempDir()
12+ cwd := "/workspace/history"
13+ session, err := NewSession(root, cwd, "test", "model", "test")
14+ if err != nil {
15+ t.Fatal(err)
16+ }
17+ oldPrompt := textMessage(provider.RoleUser, "old prompt")
18+ oldAnswer := textMessage(provider.RoleAssistant, "old answer")
19+ toolCall := provider.Message{
20+ Role: provider.RoleAssistant,
21+ Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "read"}},
22+ }
23+ toolResult := provider.Message{
24+ Role: provider.RoleTool,
25+ Content: []provider.Content{provider.ToolResultBlock{
26+ CallID: "call-1",
27+ Content: []provider.Content{provider.TextBlock{Text: "old file"}},
28+ }},
29+ }
30+ for _, message := range []provider.Message{oldPrompt, oldAnswer, toolCall, toolResult} {
31+ if err := session.AppendMessage(message); err != nil {
32+ t.Fatal(err)
33+ }
34+ }
35+ if err := session.AppendCompaction([]provider.Message{
36+ {
37+ Role: provider.RoleUser,
38+ Meta: map[string]string{"compaction": "true"},
39+ Content: []provider.Content{
40+ provider.TextBlock{Text: "summary"},
41+ },
42+ },
43+ }); err != nil {
44+ t.Fatal(err)
45+ }
46+ if err := session.AppendMessage(textMessage(provider.RoleUser, "new prompt")); err != nil {
47+ t.Fatal(err)
48+ }
49+ path := session.Path
50+ if err := session.Close(); err != nil {
51+ t.Fatal(err)
52+ }
53+
54+ history, err := ReadSessionHistory(path)
55+ if err != nil {
56+ t.Fatalf("ReadSessionHistory: %v", err)
57+ }
58+ if len(history.Segments) != 2 {
59+ t.Fatalf("history segments = %d, want 2", len(history.Segments))
60+ }
61+ if history.Segments[0].Compacted {
62+ t.Fatal("pre-compaction segment marked compacted")
63+ }
64+ if !history.Segments[1].Compacted {
65+ t.Fatal("replacement segment not marked compacted")
66+ }
67+ assertMessageTexts(t, history.Segments[0].Messages, []string{"old prompt", "old answer", "", ""})
68+ assertMessageTexts(t, history.Segments[1].Messages, []string{"summary", "new prompt"})
69+
70+ branchPath, err := BranchSessionHiddenFromHistory(path, root, cwd, "test", history.Segments[0], 1)
71+ if err != nil {
72+ t.Fatalf("BranchSessionHiddenFromHistory: %v", err)
73+ }
74+ branch, messages, err := OpenSession(branchPath)
75+ if err != nil {
76+ t.Fatalf("OpenSession branch: %v", err)
77+ }
78+ if err := branch.Close(); err != nil {
79+ t.Fatal(err)
80+ }
81+ assertMessageTexts(t, messages, []string{"old prompt"})
82+ if branch.Meta.Parent != history.Meta.ID {
83+ t.Fatalf("branch parent = %q, want %q", branch.Meta.Parent, history.Meta.ID)
84+ }
85+}
86+
87+func TestReadSessionHistoryRepairsEachSegmentIndependently(t *testing.T) {
88+ root := t.TempDir()
89+ session, err := NewSession(root, "/workspace/history-repair", "test", "model", "test")
90+ if err != nil {
91+ t.Fatal(err)
92+ }
93+ call := provider.Message{
94+ Role: provider.RoleAssistant,
95+ Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "bash"}},
96+ Time: time.Unix(1, 0),
97+ }
98+ if err := session.AppendMessage(call); err != nil {
99+ t.Fatal(err)
100+ }
101+ if err := session.AppendCompaction([]provider.Message{textMessage(provider.RoleUser, "summary")}); err != nil {
102+ t.Fatal(err)
103+ }
104+ path := session.Path
105+ if err := session.Close(); err != nil {
106+ t.Fatal(err)
107+ }
108+
109+ history, err := ReadSessionHistory(path)
110+ if err != nil {
111+ t.Fatal(err)
112+ }
113+ if len(history.Segments) != 2 {
114+ t.Fatalf("history segments = %d, want 2", len(history.Segments))
115+ }
116+ if len(history.Segments[0].Messages) != 2 {
117+ t.Fatalf("repaired old segment messages = %d, want assistant plus stub result", len(history.Segments[0].Messages))
118+ }
119+ if history.Segments[0].Messages[1].Role != provider.RoleTool {
120+ t.Fatalf("repaired old segment tail role = %q, want tool", history.Segments[0].Messages[1].Role)
121+ }
122+}
@@ -0,0 +1,122 @@
1+package core
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/patriceckhart/zot/packages/provider"
8+)
9+
10+func TestReadSessionHistoryRetainsPreCompactionSegments(t *testing.T) {
11+ root := t.TempDir()
12+ cwd := "/workspace/history"
13+ session, err := NewSession(root, cwd, "test", "model", "test")
14+ if err != nil {
15+ t.Fatal(err)
16+ }
17+ oldPrompt := textMessage(provider.RoleUser, "old prompt")
18+ oldAnswer := textMessage(provider.RoleAssistant, "old answer")
19+ toolCall := provider.Message{
20+ Role: provider.RoleAssistant,
21+ Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "read"}},
22+ }
23+ toolResult := provider.Message{
24+ Role: provider.RoleTool,
25+ Content: []provider.Content{provider.ToolResultBlock{
26+ CallID: "call-1",
27+ Content: []provider.Content{provider.TextBlock{Text: "old file"}},
28+ }},
29+ }
30+ for _, message := range []provider.Message{oldPrompt, oldAnswer, toolCall, toolResult} {
31+ if err := session.AppendMessage(message); err != nil {
32+ t.Fatal(err)
33+ }
34+ }
35+ if err := session.AppendCompaction([]provider.Message{
36+ {
37+ Role: provider.RoleUser,
38+ Meta: map[string]string{"compaction": "true"},
39+ Content: []provider.Content{
40+ provider.TextBlock{Text: "summary"},
41+ },
42+ },
43+ }); err != nil {
44+ t.Fatal(err)
45+ }
46+ if err := session.AppendMessage(textMessage(provider.RoleUser, "new prompt")); err != nil {
47+ t.Fatal(err)
48+ }
49+ path := session.Path
50+ if err := session.Close(); err != nil {
51+ t.Fatal(err)
52+ }
53+
54+ history, err := ReadSessionHistory(path)
55+ if err != nil {
56+ t.Fatalf("ReadSessionHistory: %v", err)
57+ }
58+ if len(history.Segments) != 2 {
59+ t.Fatalf("history segments = %d, want 2", len(history.Segments))
60+ }
61+ if history.Segments[0].Compacted {
62+ t.Fatal("pre-compaction segment marked compacted")
63+ }
64+ if !history.Segments[1].Compacted {
65+ t.Fatal("replacement segment not marked compacted")
66+ }
67+ assertMessageTexts(t, history.Segments[0].Messages, []string{"old prompt", "old answer", "", ""})
68+ assertMessageTexts(t, history.Segments[1].Messages, []string{"summary", "new prompt"})
69+
70+ branchPath, err := BranchSessionHiddenFromHistory(path, root, cwd, "test", history.Segments[0], 1)
71+ if err != nil {
72+ t.Fatalf("BranchSessionHiddenFromHistory: %v", err)
73+ }
74+ branch, messages, err := OpenSession(branchPath)
75+ if err != nil {
76+ t.Fatalf("OpenSession branch: %v", err)
77+ }
78+ if err := branch.Close(); err != nil {
79+ t.Fatal(err)
80+ }
81+ assertMessageTexts(t, messages, []string{"old prompt"})
82+ if branch.Meta.Parent != history.Meta.ID {
83+ t.Fatalf("branch parent = %q, want %q", branch.Meta.Parent, history.Meta.ID)
84+ }
85+}
86+
87+func TestReadSessionHistoryRepairsEachSegmentIndependently(t *testing.T) {
88+ root := t.TempDir()
89+ session, err := NewSession(root, "/workspace/history-repair", "test", "model", "test")
90+ if err != nil {
91+ t.Fatal(err)
92+ }
93+ call := provider.Message{
94+ Role: provider.RoleAssistant,
95+ Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "bash"}},
96+ Time: time.Unix(1, 0),
97+ }
98+ if err := session.AppendMessage(call); err != nil {
99+ t.Fatal(err)
100+ }
101+ if err := session.AppendCompaction([]provider.Message{textMessage(provider.RoleUser, "summary")}); err != nil {
102+ t.Fatal(err)
103+ }
104+ path := session.Path
105+ if err := session.Close(); err != nil {
106+ t.Fatal(err)
107+ }
108+
109+ history, err := ReadSessionHistory(path)
110+ if err != nil {
111+ t.Fatal(err)
112+ }
113+ if len(history.Segments) != 2 {
114+ t.Fatalf("history segments = %d, want 2", len(history.Segments))
115+ }
116+ if len(history.Segments[0].Messages) != 2 {
117+ t.Fatalf("repaired old segment messages = %d, want assistant plus stub result", len(history.Segments[0].Messages))
118+ }
119+ if history.Segments[0].Messages[1].Role != provider.RoleTool {
120+ t.Fatalf("repaired old segment tail role = %q, want tool", history.Segments[0].Messages[1].Role)
121+ }
122+}
M packages/core/session_portable.go +36 -11
@@ -308,6 +308,36 @@ func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hi
308308 limit++
309309 }
310310
311+ return writeBranchSession(root, cwd, version, snapshot.Meta, snapshot.Messages, snapshot.UsageCheckpoints, limit, hideFromSessions)
312+}
313+
314+// BranchSessionHiddenFromHistory creates a hidden tree branch from a
315+// pre-compaction transcript segment. The segment is already repaired and is
316+// kept separate from the effective snapshot so normal resume semantics remain
317+// unchanged while older fork points stay usable.
318+func BranchSessionHiddenFromHistory(parentPath, root, cwd, version string, segment SessionHistorySegment, upToMessageIdx int) (string, error) {
319+ if parentPath == "" {
320+ return "", errors.New("branch: parent path is empty")
321+ }
322+ if upToMessageIdx < 0 {
323+ return "", errors.New("branch: upToMessageIdx must be >= 0")
324+ }
325+ if upToMessageIdx > len(segment.Messages) {
326+ return "", fmt.Errorf("branch: upToMessageIdx %d exceeds historical message count %d", upToMessageIdx, len(segment.Messages))
327+ }
328+
329+ snapshot, err := ReadSessionSnapshot(parentPath)
330+ if err != nil {
331+ return "", fmt.Errorf("branch: read parent snapshot: %w", err)
332+ }
333+ limit := upToMessageIdx
334+ if limit > 0 && limit < len(segment.Messages) && messageHasToolCalls(segment.Messages[limit-1]) && segment.Messages[limit].Role == provider.RoleTool {
335+ limit++
336+ }
337+ return writeBranchSession(root, cwd, version, snapshot.Meta, segment.Messages, segment.UsageCheckpoints, limit, true)
338+}
339+
340+func writeBranchSession(root, cwd, version string, parent SessionMeta, messages []provider.Message, checkpoints []SessionUsageCheckpoint, limit int, hideFromSessions bool) (string, error) {
311341 dir := SessionsDir(root, cwd)
312342 if err := os.MkdirAll(dir, 0o755); err != nil {
313343 return "", err
@@ -341,11 +371,11 @@ func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hi
341371 branchMeta := SessionMeta{
342372 ID: newID,
343373 CWD: cwd,
344- Model: snapshot.Meta.Model,
345- Provider: snapshot.Meta.Provider,
374+ Model: parent.Model,
375+ Provider: parent.Provider,
346376 Started: time.Now().UTC(),
347377 Version: version,
348- Parent: snapshot.Meta.ID,
378+ Parent: parent.ID,
349379 ForkPoint: limit,
350380 HideFromSessions: hideFromSessions,
351381 }
@@ -360,11 +390,8 @@ func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hi
360390 return "", err
361391 }
362392
363- // Always materialize the shared effective prefix. Replaying raw rows here
364- // would lose compaction replacement or synthetic tool results and make the
365- // child differ from the transcript that was displayed and selected.
366393 for idx := 0; idx < limit; idx++ {
367- msg := snapshot.Messages[idx]
394+ msg := messages[idx]
368395 line, err := json.Marshal(sessionLine{Type: "message", Message: &msg})
369396 if err != nil {
370397 return "", fmt.Errorf("branch: marshal message %d: %w", idx, err)
@@ -378,10 +405,8 @@ func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hi
378405 }
379406
380407 // Preserve every cumulative checkpoint that belongs to this prefix. The
381- // final two rows are needed to reconstruct LastTurnUsage as a delta; a
382- // branch containing only the latest row would incorrectly report the
383- // whole cumulative total as its final turn.
384- for _, checkpoint := range snapshot.UsageCheckpoints {
408+ // final two rows are needed to reconstruct LastTurnUsage as a delta.
409+ for _, checkpoint := range checkpoints {
385410 if checkpoint.MessageCount > limit {
386411 continue
387412 }
@@ -308,6 +308,36 @@ func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hi
308 limit++308 limit++
309 }309 }
310 310
311+ return writeBranchSession(root, cwd, version, snapshot.Meta, snapshot.Messages, snapshot.UsageCheckpoints, limit, hideFromSessions)
312+}
313+
314+// BranchSessionHiddenFromHistory creates a hidden tree branch from a
315+// pre-compaction transcript segment. The segment is already repaired and is
316+// kept separate from the effective snapshot so normal resume semantics remain
317+// unchanged while older fork points stay usable.
318+func BranchSessionHiddenFromHistory(parentPath, root, cwd, version string, segment SessionHistorySegment, upToMessageIdx int) (string, error) {
319+ if parentPath == "" {
320+ return "", errors.New("branch: parent path is empty")
321+ }
322+ if upToMessageIdx < 0 {
323+ return "", errors.New("branch: upToMessageIdx must be >= 0")
324+ }
325+ if upToMessageIdx > len(segment.Messages) {
326+ return "", fmt.Errorf("branch: upToMessageIdx %d exceeds historical message count %d", upToMessageIdx, len(segment.Messages))
327+ }
328+
329+ snapshot, err := ReadSessionSnapshot(parentPath)
330+ if err != nil {
331+ return "", fmt.Errorf("branch: read parent snapshot: %w", err)
332+ }
333+ limit := upToMessageIdx
334+ if limit > 0 && limit < len(segment.Messages) && messageHasToolCalls(segment.Messages[limit-1]) && segment.Messages[limit].Role == provider.RoleTool {
335+ limit++
336+ }
337+ return writeBranchSession(root, cwd, version, snapshot.Meta, segment.Messages, segment.UsageCheckpoints, limit, true)
338+}
339+
340+func writeBranchSession(root, cwd, version string, parent SessionMeta, messages []provider.Message, checkpoints []SessionUsageCheckpoint, limit int, hideFromSessions bool) (string, error) {
311 dir := SessionsDir(root, cwd)341 dir := SessionsDir(root, cwd)
312 if err := os.MkdirAll(dir, 0o755); err != nil {342 if err := os.MkdirAll(dir, 0o755); err != nil {
313 return "", err343 return "", err
@@ -341,11 +371,11 @@ func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hi
341 branchMeta := SessionMeta{371 branchMeta := SessionMeta{
342 ID: newID,372 ID: newID,
343 CWD: cwd,373 CWD: cwd,
344- Model: snapshot.Meta.Model,374+ Model: parent.Model,
345- Provider: snapshot.Meta.Provider,375+ Provider: parent.Provider,
346 Started: time.Now().UTC(),376 Started: time.Now().UTC(),
347 Version: version,377 Version: version,
348- Parent: snapshot.Meta.ID,378+ Parent: parent.ID,
349 ForkPoint: limit,379 ForkPoint: limit,
350 HideFromSessions: hideFromSessions,380 HideFromSessions: hideFromSessions,
351 }381 }
@@ -360,11 +390,8 @@ func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hi
360 return "", err390 return "", err
361 }391 }
362 392
363- // Always materialize the shared effective prefix. Replaying raw rows here
364- // would lose compaction replacement or synthetic tool results and make the
365- // child differ from the transcript that was displayed and selected.
366 for idx := 0; idx < limit; idx++ {393 for idx := 0; idx < limit; idx++ {
367- msg := snapshot.Messages[idx]394+ msg := messages[idx]
368 line, err := json.Marshal(sessionLine{Type: "message", Message: &msg})395 line, err := json.Marshal(sessionLine{Type: "message", Message: &msg})
369 if err != nil {396 if err != nil {
370 return "", fmt.Errorf("branch: marshal message %d: %w", idx, err)397 return "", fmt.Errorf("branch: marshal message %d: %w", idx, err)
@@ -378,10 +405,8 @@ func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hi
378 }405 }
379 406
380 // Preserve every cumulative checkpoint that belongs to this prefix. The407 // Preserve every cumulative checkpoint that belongs to this prefix. The
381- // final two rows are needed to reconstruct LastTurnUsage as a delta; a408+ // final two rows are needed to reconstruct LastTurnUsage as a delta.
382- // branch containing only the latest row would incorrectly report the409+ for _, checkpoint := range checkpoints {
383- // whole cumulative total as its final turn.
384- for _, checkpoint := range snapshot.UsageCheckpoints {
385 if checkpoint.MessageCount > limit {410 if checkpoint.MessageCount > limit {
386 continue411 continue
387 }412 }
A packages/core/session_usage_test.go +84 -0
@@ -0,0 +1,84 @@
1+package core
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/patriceckhart/zot/packages/provider"
8+)
9+
10+func TestSessionUsageDetailResetsContextAfterCompaction(t *testing.T) {
11+ t.Run("compaction is latest row", func(t *testing.T) {
12+ sess := newUsageTestSession(t)
13+ before := provider.Usage{
14+ InputTokens: 180,
15+ OutputTokens: 20,
16+ CacheReadTokens: 10,
17+ CostUSD: 1.25,
18+ }
19+ if err := sess.AppendUsage(before, before); err != nil {
20+ t.Fatal(err)
21+ }
22+ if err := sess.AppendCompaction([]provider.Message{{
23+ Role: provider.RoleUser,
24+ Content: []provider.Content{provider.TextBlock{Text: "summary"}},
25+ Time: time.Now().UTC(),
26+ }}); err != nil {
27+ t.Fatal(err)
28+ }
29+ if err := sess.Close(); err != nil {
30+ t.Fatal(err)
31+ }
32+
33+ cumulative, lastTurn, err := SessionUsageDetail(sess.Path)
34+ if err != nil {
35+ t.Fatal(err)
36+ }
37+ if cumulative.InputTokens != before.InputTokens || cumulative.CostUSD != before.CostUSD {
38+ t.Fatalf("cumulative usage = %+v, want %+v", cumulative, before)
39+ }
40+ if lastTurn != (provider.Usage{}) {
41+ t.Fatalf("last turn after compaction = %+v, want zero context usage", lastTurn)
42+ }
43+ })
44+
45+ t.Run("post-compaction usage wins", func(t *testing.T) {
46+ sess := newUsageTestSession(t)
47+ before := provider.Usage{InputTokens: 180, OutputTokens: 20, CacheReadTokens: 10}
48+ if err := sess.AppendUsage(before, before); err != nil {
49+ t.Fatal(err)
50+ }
51+ if err := sess.AppendCompaction([]provider.Message{{
52+ Role: provider.RoleUser,
53+ Content: []provider.Content{provider.TextBlock{Text: "summary"}},
54+ Time: time.Now().UTC(),
55+ }}); err != nil {
56+ t.Fatal(err)
57+ }
58+ after := provider.Usage{InputTokens: 20, OutputTokens: 5, CacheReadTokens: 30}
59+ cumulative := provider.Usage{InputTokens: 200, OutputTokens: 25, CacheReadTokens: 30}
60+ if err := sess.AppendUsage(after, cumulative); err != nil {
61+ t.Fatal(err)
62+ }
63+ if err := sess.Close(); err != nil {
64+ t.Fatal(err)
65+ }
66+
67+ _, lastTurn, err := SessionUsageDetail(sess.Path)
68+ if err != nil {
69+ t.Fatal(err)
70+ }
71+ if lastTurn.InputTokens != after.InputTokens || lastTurn.OutputTokens != after.OutputTokens {
72+ t.Fatalf("post-compaction last turn = %+v, want input/output %+v", lastTurn, after)
73+ }
74+ })
75+}
76+
77+func newUsageTestSession(t *testing.T) *Session {
78+ t.Helper()
79+ sess, err := NewSession(t.TempDir(), "/workspace", "anthropic", "test-model", "test")
80+ if err != nil {
81+ t.Fatal(err)
82+ }
83+ return sess
84+}
@@ -0,0 +1,84 @@
1+package core
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/patriceckhart/zot/packages/provider"
8+)
9+
10+func TestSessionUsageDetailResetsContextAfterCompaction(t *testing.T) {
11+ t.Run("compaction is latest row", func(t *testing.T) {
12+ sess := newUsageTestSession(t)
13+ before := provider.Usage{
14+ InputTokens: 180,
15+ OutputTokens: 20,
16+ CacheReadTokens: 10,
17+ CostUSD: 1.25,
18+ }
19+ if err := sess.AppendUsage(before, before); err != nil {
20+ t.Fatal(err)
21+ }
22+ if err := sess.AppendCompaction([]provider.Message{{
23+ Role: provider.RoleUser,
24+ Content: []provider.Content{provider.TextBlock{Text: "summary"}},
25+ Time: time.Now().UTC(),
26+ }}); err != nil {
27+ t.Fatal(err)
28+ }
29+ if err := sess.Close(); err != nil {
30+ t.Fatal(err)
31+ }
32+
33+ cumulative, lastTurn, err := SessionUsageDetail(sess.Path)
34+ if err != nil {
35+ t.Fatal(err)
36+ }
37+ if cumulative.InputTokens != before.InputTokens || cumulative.CostUSD != before.CostUSD {
38+ t.Fatalf("cumulative usage = %+v, want %+v", cumulative, before)
39+ }
40+ if lastTurn != (provider.Usage{}) {
41+ t.Fatalf("last turn after compaction = %+v, want zero context usage", lastTurn)
42+ }
43+ })
44+
45+ t.Run("post-compaction usage wins", func(t *testing.T) {
46+ sess := newUsageTestSession(t)
47+ before := provider.Usage{InputTokens: 180, OutputTokens: 20, CacheReadTokens: 10}
48+ if err := sess.AppendUsage(before, before); err != nil {
49+ t.Fatal(err)
50+ }
51+ if err := sess.AppendCompaction([]provider.Message{{
52+ Role: provider.RoleUser,
53+ Content: []provider.Content{provider.TextBlock{Text: "summary"}},
54+ Time: time.Now().UTC(),
55+ }}); err != nil {
56+ t.Fatal(err)
57+ }
58+ after := provider.Usage{InputTokens: 20, OutputTokens: 5, CacheReadTokens: 30}
59+ cumulative := provider.Usage{InputTokens: 200, OutputTokens: 25, CacheReadTokens: 30}
60+ if err := sess.AppendUsage(after, cumulative); err != nil {
61+ t.Fatal(err)
62+ }
63+ if err := sess.Close(); err != nil {
64+ t.Fatal(err)
65+ }
66+
67+ _, lastTurn, err := SessionUsageDetail(sess.Path)
68+ if err != nil {
69+ t.Fatal(err)
70+ }
71+ if lastTurn.InputTokens != after.InputTokens || lastTurn.OutputTokens != after.OutputTokens {
72+ t.Fatalf("post-compaction last turn = %+v, want input/output %+v", lastTurn, after)
73+ }
74+ })
75+}
76+
77+func newUsageTestSession(t *testing.T) *Session {
78+ t.Helper()
79+ sess, err := NewSession(t.TempDir(), "/workspace", "anthropic", "test-model", "test")
80+ if err != nil {
81+ t.Fatal(err)
82+ }
83+ return sess
84+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close