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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 313 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 421 | ### `/jail` | |
| 422 | 422 | ||
| @@ -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 | |||
| 487 | 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 | 492 | ## Commands | |
| 493 | 493 | ||
| @@ -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 | ## Commands | 492 | ## Commands | ||
| 493 | 493 | ||||
M packages/agent/cli.go +4 -2
| @@ -201,9 +201,11 @@ func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Mes | |||
| 201 | 201 | addToolNames(msg.AddedToolNames) | |
| 202 | 202 | } | |
| 203 | 203 | // 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 { | |
| 206 | 207 | out = append(out, msgs[0]) | |
| 208 | + | start++ | |
| 207 | 209 | } | |
| 208 | 210 | // Avoid hydrating a tail that starts with orphan tool_result rows; | |
| 209 | 211 | // 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 an | 203 | // 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | + | "fmt" | |
| 6 | 7 | "os" | |
| 7 | 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 | 66 | func TestLiveInteractiveAgentUsesReplacementAgentForSessionResume(t *testing.T) { | |
| 37 | 67 | startup := core.NewAgent(nil, "startup-model", "", nil) | |
| 38 | 68 | 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 @@ | |||
| 1 | 1 | package modes | |
| 2 | 2 | ||
| 3 | - | import "testing" | |
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 7 | + | ) | |
| 4 | 8 | ||
| 5 | 9 | func TestNormalizeAutoCompactThreshold(t *testing.T) { | |
| 6 | 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 | 84 | func TestShouldAutoCompactUsesConfiguredThreshold(t *testing.T) { | |
| 31 | 85 | tests := []struct { | |
| 32 | 86 | name string | |
| @@ -1,6 +1,10 @@ | |||||
| 1 | package modes | 1 | 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 string | 86 | name string | ||
M packages/agent/modes/compact_queue_test.go +91 -7
| @@ -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 | 184 | func TestPreTurnCompactionPreservesPromptImages(t *testing.T) { | |
| 98 | 185 | client := &compactQueueClient{ | |
| 99 | 186 | compactionStarted: make(chan struct{}), | |
| @@ -101,13 +188,10 @@ func TestPreTurnCompactionPreservesPromptImages(t *testing.T) { | |||
| 101 | 188 | followUpRequest: make(chan provider.Request, 1), | |
| 102 | 189 | } | |
| 103 | 190 | 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 | + | }}) | |
| 111 | 195 | threshold := 70 | |
| 112 | 196 | interactive := NewInteractive(InteractiveConfig{ | |
| 113 | 197 | 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 := 70 | 195 | 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) { | |||
| 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 | 101 | func TestInputHistoryNoLongerUsesLeftRight(t *testing.T) { | |
| 68 | 102 | ag := core.NewAgent(nil, "", "", nil) | |
| 69 | 103 | 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 { | |||
| 1889 | 1889 | ||
| 1890 | 1890 | const ( | |
| 1891 | 1891 | hiddenOpenAIImageMirrorPrefix = "Tool output included the following image content:" | |
| 1892 | + | autoCompactContinueMetaKey = "auto_compact_continue" | |
| 1892 | 1893 | shellEscapeMetaKey = "shell_escape" | |
| 1893 | 1894 | ) | |
| 1894 | 1895 | ||
| @@ -1907,6 +1908,9 @@ func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message | |||
| 1907 | 1908 | } | |
| 1908 | 1909 | ||
| 1909 | 1910 | func isHiddenTranscriptMessage(m provider.Message) bool { | |
| 1911 | + | if m.Meta[autoCompactContinueMetaKey] == "true" { | |
| 1912 | + | return true | |
| 1913 | + | } | |
| 1910 | 1914 | if m.Role != provider.RoleUser || len(m.Content) == 0 { | |
| 1911 | 1915 | return false | |
| 1912 | 1916 | } | |
| @@ -3029,7 +3033,7 @@ func (i *Interactive) inputHistory() []string { | |||
| 3029 | 3033 | msgs := i.agent.Messages() | |
| 3030 | 3034 | hist := make([]string, 0, len(msgs)) | |
| 3031 | 3035 | for _, m := range msgs { | |
| 3032 | - | if m.Role != provider.RoleUser || isHiddenTranscriptMessage(m) || m.Meta[shellEscapeMetaKey] == "true" { | |
| 3036 | + | if !isForkableUserMessage(m) { | |
| 3033 | 3037 | continue | |
| 3034 | 3038 | } | |
| 3035 | 3039 | text := userMessageText(m) | |
| @@ -5793,6 +5797,32 @@ func (i *Interactive) clearPendingCompactTurnLocked() { | |||
| 5793 | 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 | 5826 | // runCompact invokes core.Agent.Compact and reflects the progress in | |
| 5797 | 5827 | // the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c | |
| 5798 | 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 | 5861 | go func() { | |
| 5832 | 5862 | // Sink discards deltas — we don't stream the summary to the UI. | |
| 5833 | 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 | 5876 | _ = summary | |
| 5836 | 5877 | i.mu.Lock() | |
| 5837 | 5878 | i.busy = false | |
| @@ -5848,6 +5889,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||
| 5848 | 5889 | var nextImages []provider.ImageBlock | |
| 5849 | 5890 | var hasNext bool | |
| 5850 | 5891 | var continueExisting bool | |
| 5892 | + | var continueAutomatically bool | |
| 5851 | 5893 | ||
| 5852 | 5894 | switch { | |
| 5853 | 5895 | case err != nil && ctx.Err() != nil: | |
| @@ -5907,20 +5949,35 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||
| 5907 | 5949 | case len(i.queued) > 0: | |
| 5908 | 5950 | next, i.queued = i.queued[0], i.queued[1:] | |
| 5909 | 5951 | 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 | 5966 | i.mu.Unlock() | |
| 5913 | 5967 | i.invalidate() | |
| 5914 | 5968 | ||
| 5915 | - | if hasNext || continueExisting { | |
| 5969 | + | if hasNext || continueExisting || continueAutomatically { | |
| 5916 | 5970 | p := i.runCtx | |
| 5917 | 5971 | if p == nil { | |
| 5918 | 5972 | p = context.Background() | |
| 5919 | 5973 | } | |
| 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: | |
| 5923 | 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 | 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 | 6125 | if i.agent == nil { | |
| 6063 | 6126 | // Text startup pre cannot run without credentials; continue so | |
| 6064 | 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 | 6212 | go func() { | |
| 6150 | 6213 | var err error | |
| 6151 | - | if overflowRecoveryAttempted { | |
| 6214 | + | if continueExisting { | |
| 6152 | 6215 | err = i.agent.Continue(ctx, sink) | |
| 6153 | 6216 | } else { | |
| 6154 | 6217 | err = i.agent.Prompt(ctx, prompt, images, sink) | |
| @@ -6402,6 +6465,36 @@ func shouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool { | |||
| 6402 | 6465 | 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 | 6498 | // shouldAutoCompactLocked reports whether the last turn pushed context | |
| 6406 | 6499 | // usage past the auto-compact threshold. Must be called with i.mu | |
| 6407 | 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 | 7563 | // Read and validate the selected row before flushing or creating anything. | |
| 7471 | 7564 | // 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 | |
| 7477 | 7590 | } | |
| 7478 | - | _ = sess.Close() | |
| 7479 | 7591 | selection, err := sessionTreeSelection(msgs, target) | |
| 7480 | 7592 | if err != nil { | |
| 7481 | 7593 | i.setSessionTreeError("tree: " + err.Error()) | |
| @@ -7484,7 +7596,12 @@ func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo in | |||
| 7484 | 7596 | if i.cfg.FlushSession != nil { | |
| 7485 | 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 | 7605 | if err != nil { | |
| 7489 | 7606 | i.setSessionTreeError("tree: " + err.Error()) | |
| 7490 | 7607 | 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 false | 1915 | 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 | continue | 3037 | 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 in | 5826 | // 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+c | 5827 | // 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 | _ = summary | 5876 | _ = summary | ||
| 5836 | i.mu.Lock() | 5877 | i.mu.Lock() | ||
| 5837 | i.busy = false | 5878 | i.busy = false | ||
| @@ -5848,6 +5889,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||||
| 5848 | var nextImages []provider.ImageBlock | 5889 | var nextImages []provider.ImageBlock | ||
| 5849 | var hasNext bool | 5890 | var hasNext bool | ||
| 5850 | var continueExisting bool | 5891 | 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 = true | 5951 | 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.runCtx | 5970 | 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 so | 6126 | // 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 error | 6213 | 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)/100 | 6465 | 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 context | 6498 | // shouldAutoCompactLocked reports whether the last turn pushed context | ||
| 6406 | // usage past the auto-compact threshold. Must be called with i.mu | 6499 | // 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 a | 7564 | // 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 | - | return | 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 | |||
| 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 | return | 7607 | return | ||
M packages/agent/modes/jump_dialog.go +2 -2
| @@ -244,7 +244,7 @@ func buildJumpTargets(msgs []provider.Message) []jumpTarget { | |||
| 244 | 244 | var out []jumpTarget | |
| 245 | 245 | turn := 0 | |
| 246 | 246 | for i, m := range msgs { | |
| 247 | - | if m.Role != provider.RoleUser { | |
| 247 | + | if !isForkableUserMessage(m) { | |
| 248 | 248 | continue | |
| 249 | 249 | } | |
| 250 | 250 | turn++ | |
| @@ -286,7 +286,7 @@ func firstLineOfUserMessage(m provider.Message) string { | |||
| 286 | 286 | func countToolsUntilNextUser(msgs []provider.Message, i int) int { | |
| 287 | 287 | n := 0 | |
| 288 | 288 | for j := i + 1; j < len(msgs); j++ { | |
| 289 | - | if msgs[j].Role == provider.RoleUser { | |
| 289 | + | if isForkableUserMessage(msgs[j]) { | |
| 290 | 290 | break | |
| 291 | 291 | } | |
| 292 | 292 | if msgs[j].Role == provider.RoleAssistant { | |
| @@ -244,7 +244,7 @@ func buildJumpTargets(msgs []provider.Message) []jumpTarget { | |||||
| 244 | var out []jumpTarget | 244 | var out []jumpTarget | ||
| 245 | turn := 0 | 245 | 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 | continue | 248 | 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 := 0 | 287 | 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 | break | 290 | 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 { | |||
| 36 | 36 | } | |
| 37 | 37 | ||
| 38 | 38 | // 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. | |
| 44 | 44 | type sessionTreeTarget struct { | |
| 45 | 45 | SourcePath string | |
| 46 | 46 | EffectiveIndex int | |
| @@ -48,6 +48,8 @@ type sessionTreeTarget struct { | |||
| 48 | 48 | Role provider.Role | |
| 49 | 49 | UserDraft string | |
| 50 | 50 | Boundary sessionTreeBoundary | |
| 51 | + | Historical bool | |
| 52 | + | HistorySegment int | |
| 51 | 53 | } | |
| 52 | 54 | ||
| 53 | 55 | func (t sessionTreeTarget) IsBoundary() bool { | |
| @@ -112,6 +114,7 @@ type sessionTreeAction struct { | |||
| 112 | 114 | type sessionTreeSnapshot struct { | |
| 113 | 115 | path string | |
| 114 | 116 | messages []provider.Message | |
| 117 | + | history []core.SessionHistorySegment | |
| 115 | 118 | } | |
| 116 | 119 | ||
| 117 | 120 | const defaultSessionTreeRows = 12 | |
| @@ -296,7 +299,7 @@ func (d *sessionTreeDialog) Render(th tui.Theme, width int) []string { | |||
| 296 | 299 | return lines | |
| 297 | 300 | } | |
| 298 | 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 | 304 | d.followCursor() | |
| 302 | 305 | start := d.viewTop | |
| @@ -387,12 +390,16 @@ func preflightSessionTreeFamily(root *core.TreeNode) (map[string]sessionTreeSnap | |||
| 387 | 390 | return nil | |
| 388 | 391 | } | |
| 389 | 392 | visiting[path] = true | |
| 390 | - | snapshot, err := core.ReadSessionSnapshot(path) | |
| 393 | + | history, err := core.ReadSessionHistory(path) | |
| 391 | 394 | if err != nil { | |
| 392 | 395 | delete(visiting, path) | |
| 393 | 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 | 403 | for _, child := range node.Children { | |
| 397 | 404 | if err := walk(child); err != nil { | |
| 398 | 405 | delete(visiting, path) | |
| @@ -478,7 +485,13 @@ func flattenSessionNode(node *core.TreeNode, currentPath string, snapshots map[s | |||
| 478 | 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 | 495 | childrenAt := make(map[int][]*core.TreeNode) | |
| 483 | 496 | var stale []*core.TreeNode | |
| 484 | 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 | 521 | var out []sessionTreeItem | |
| 522 | + | for _, item := range historyItems { | |
| 523 | + | if item.target.Historical { | |
| 524 | + | out = append(out, item) | |
| 525 | + | } | |
| 526 | + | } | |
| 509 | 527 | if boundary != sessionTreeMessageBoundary { | |
| 510 | 528 | turn := treeTurnAtBoundary(msgs, selectionBoundary) | |
| 511 | 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 | 540 | // children that forked in the hidden copied prefix are normalized to its | |
| 523 | 541 | // first visible index above, so they remain discoverable too. | |
| 524 | 542 | 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 | + | } | |
| 527 | 547 | emitChildren(childrenAt[idx+1], false) | |
| 528 | 548 | } | |
| 529 | 549 | // 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 | 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 | 618 | out := make([]sessionTreeItem, 0, len(msgs)) | |
| 585 | 619 | turn := 0 | |
| 586 | 620 | lastTurn := 0 | |
| 621 | + | visible := make([]int, 0, len(msgs)) | |
| 587 | 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 | 630 | if msg.Role == provider.RoleUser { | |
| 589 | 631 | turn++ | |
| 590 | 632 | lastTurn = turn | |
| @@ -606,6 +648,8 @@ func buildSessionTreeItems(path string, msgs []provider.Message, depth int, curr | |||
| 606 | 648 | Role: msg.Role, | |
| 607 | 649 | UserDraft: draft, | |
| 608 | 650 | Boundary: sessionTreeMessageBoundary, | |
| 651 | + | Historical: historical, | |
| 652 | + | HistorySegment: historySegment, | |
| 609 | 653 | } | |
| 610 | 654 | out = append(out, sessionTreeItem{ | |
| 611 | 655 | label: fmt.Sprintf("%s: %s", roleLabel, preview), | |
| @@ -615,13 +659,83 @@ func buildSessionTreeItems(path string, msgs []provider.Message, depth int, curr | |||
| 615 | 659 | prompt: draft, | |
| 616 | 660 | path: path, | |
| 617 | 661 | depth: depth, | |
| 618 | - | current: currentPath && idx == len(msgs)-1, | |
| 662 | + | current: currentPath && visibleIndex == len(visible)-1, | |
| 619 | 663 | target: target, | |
| 620 | 664 | }) | |
| 621 | 665 | } | |
| 622 | 666 | 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 | 739 | func completeUserDraft(msg provider.Message) string { | |
| 626 | 740 | var text []string | |
| 627 | 741 | 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 (the | 39 | + | // EffectiveIndex and SelectionBoundary refer to the row's provider-valid |
| 40 | - | // snapshot returned by core.OpenSession), while SelectionBoundary is the | 40 | + | // segment. Ordinary rows use SourcePath's effective transcript; historical |
| 41 | - | // message count to pass to core.BranchSessionHidden. Keeping both values is | 41 | + | // rows use the pre-compaction segment identified by HistorySegment. Keeping |
| 42 | - | // important for a user row: selecting it branches before that row so the | 42 | + | // 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 string | 45 | SourcePath string | ||
| 46 | EffectiveIndex int | 46 | EffectiveIndex int | ||
| @@ -48,6 +48,8 @@ type sessionTreeTarget struct { | |||||
| 48 | Role provider.Role | 48 | Role provider.Role | ||
| 49 | UserDraft string | 49 | UserDraft string | ||
| 50 | Boundary sessionTreeBoundary | 50 | 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 string | 115 | path string | ||
| 114 | messages []provider.Message | 116 | messages []provider.Message | ||
| 117 | + | history []core.SessionHistorySegment | |||
| 115 | } | 118 | } | ||
| 116 | 119 | ||||
| 117 | const defaultSessionTreeRows = 12 | 120 | const defaultSessionTreeRows = 12 | ||
| @@ -296,7 +299,7 @@ func (d *sessionTreeDialog) Render(th tui.Theme, width int) []string { | |||||
| 296 | return lines | 299 | 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.viewTop | 305 | start := d.viewTop | ||
| @@ -387,12 +390,16 @@ func preflightSessionTreeFamily(root *core.TreeNode) (map[string]sessionTreeSnap | |||||
| 387 | return nil | 390 | return nil | ||
| 388 | } | 391 | } | ||
| 389 | visiting[path] = true | 392 | 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.TreeNode | 496 | 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 []sessionTreeItem | 521 | 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 its | 540 | // 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. Their | 549 | // 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 := 0 | 619 | turn := 0 | ||
| 586 | lastTurn := 0 | 620 | 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 = turn | 632 | 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 out | 666 | 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 []string | 740 | 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) { | |||
| 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 | 198 | func TestSessionTreeUsesCurrentFamilyAndNoRootFallback(t *testing.T) { | |
| 50 | 199 | root := t.TempDir() | |
| 51 | 200 | 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 { | |||
| 91 | 91 | // row was written. MessageCount uses the effective transcript (after the | |
| 92 | 92 | // latest compaction and tool-pair repair), not the number of raw JSONL rows. | |
| 93 | 93 | type SessionUsageCheckpoint struct { | |
| 94 | - | MessageCount int | |
| 95 | - | Cumulative provider.Usage | |
| 94 | + | MessageCount int | |
| 95 | + | Cumulative provider.Usage | |
| 96 | + | CompactionGeneration int | |
| 96 | 97 | } | |
| 97 | 98 | ||
| 98 | 99 | // SessionSnapshot is the effective, in-memory view of a session file. | |
| @@ -100,12 +101,31 @@ type SessionUsageCheckpoint struct { | |||
| 100 | 101 | // JSONL audit history is deliberately not exposed here because message | |
| 101 | 102 | // indices used by tree navigation and branching must agree. | |
| 102 | 103 | 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 | |
| 105 | 117 | Messages []provider.Message | |
| 106 | 118 | 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 | 129 | // SessionsDir returns the per-cwd sessions directory under root. | |
| 110 | 130 | func SessionsDir(root, cwd string) string { | |
| 111 | 131 | sum := sha256.Sum256([]byte(cwd)) | |
| @@ -204,7 +224,14 @@ func SessionUsageDetail(path string) (cumulative, lastTurn provider.Usage, err e | |||
| 204 | 224 | if len(snapshot.UsageCheckpoints) == 0 { | |
| 205 | 225 | return provider.Usage{}, provider.Usage{}, nil | |
| 206 | 226 | } | |
| 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 | |
| 208 | 235 | var prev provider.Usage | |
| 209 | 236 | if len(snapshot.UsageCheckpoints) > 1 { | |
| 210 | 237 | prev = snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-2].Cumulative | |
| @@ -335,6 +362,8 @@ func ReadSessionSnapshot(path string) (SessionSnapshot, error) { | |||
| 335 | 362 | return SessionSnapshot{}, fmt.Errorf("session snapshot %q: file is empty", path) | |
| 336 | 363 | } | |
| 337 | 364 | ||
| 365 | + | snapshot.CompactionGeneration = generation | |
| 366 | + | ||
| 338 | 367 | // Repair only after compaction replacement. This makes synthetic tool | |
| 339 | 368 | // results and the removal of orphaned results part of the same indexed | |
| 340 | 369 | // stream that OpenSession returns and that BranchSession materializes. | |
| @@ -353,13 +382,136 @@ func ReadSessionSnapshot(path string) (SessionSnapshot, error) { | |||
| 353 | 382 | count = len(rawMessages) | |
| 354 | 383 | } | |
| 355 | 384 | snapshot.UsageCheckpoints = append(snapshot.UsageCheckpoints, SessionUsageCheckpoint{ | |
| 356 | - | MessageCount: count, | |
| 357 | - | Cumulative: checkpoint.cumulative, | |
| 385 | + | MessageCount: count, | |
| 386 | + | Cumulative: checkpoint.cumulative, | |
| 387 | + | CompactionGeneration: checkpoint.generation, | |
| 358 | 388 | }) | |
| 359 | 389 | } | |
| 360 | 390 | 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 | 515 | // forEachStrictJSONLLine is the complete-read counterpart to | |
| 364 | 516 | // forEachJSONLLine. It rejects malformed and blank rows and reports the | |
| 365 | 517 | // 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 the | 91 | // 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 int | 94 | + | MessageCount int |
| 95 | - | Cumulative provider.Usage | 95 | + | 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 message | 101 | // 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 SessionMeta | 104 | + | Meta SessionMeta |
| 104 | - | Title string | 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 | |||
| 105 | Messages []provider.Message | 117 | Messages []provider.Message | ||
| 106 | UsageCheckpoints []SessionUsageCheckpoint | 118 | 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{}, nil | 225 | return provider.Usage{}, provider.Usage{}, nil | ||
| 206 | } | 226 | } | ||
| 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 | |||
| 208 | var prev provider.Usage | 235 | var prev provider.Usage | ||
| 209 | if len(snapshot.UsageCheckpoints) > 1 { | 236 | if len(snapshot.UsageCheckpoints) > 1 { | ||
| 210 | prev = snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-2].Cumulative | 237 | 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 tool | 367 | // Repair only after compaction replacement. This makes synthetic tool | ||
| 339 | // results and the removal of orphaned results part of the same indexed | 368 | // 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, nil | 390 | 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 to | 515 | // forEachStrictJSONLLine is the complete-read counterpart to | ||
| 364 | // forEachJSONLLine. It rejects malformed and blank rows and reports the | 516 | // 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 | |||
| 308 | 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 | 341 | dir := SessionsDir(root, cwd) | |
| 312 | 342 | if err := os.MkdirAll(dir, 0o755); err != nil { | |
| 313 | 343 | return "", err | |
| @@ -341,11 +371,11 @@ func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hi | |||
| 341 | 371 | branchMeta := SessionMeta{ | |
| 342 | 372 | ID: newID, | |
| 343 | 373 | CWD: cwd, | |
| 344 | - | Model: snapshot.Meta.Model, | |
| 345 | - | Provider: snapshot.Meta.Provider, | |
| 374 | + | Model: parent.Model, | |
| 375 | + | Provider: parent.Provider, | |
| 346 | 376 | Started: time.Now().UTC(), | |
| 347 | 377 | Version: version, | |
| 348 | - | Parent: snapshot.Meta.ID, | |
| 378 | + | Parent: parent.ID, | |
| 349 | 379 | ForkPoint: limit, | |
| 350 | 380 | HideFromSessions: hideFromSessions, | |
| 351 | 381 | } | |
| @@ -360,11 +390,8 @@ func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hi | |||
| 360 | 390 | 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 | 393 | for idx := 0; idx < limit; idx++ { | |
| 367 | - | msg := snapshot.Messages[idx] | |
| 394 | + | msg := messages[idx] | |
| 368 | 395 | line, err := json.Marshal(sessionLine{Type: "message", Message: &msg}) | |
| 369 | 396 | if err != nil { | |
| 370 | 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 | 407 | // 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 { | |
| 385 | 410 | if checkpoint.MessageCount > limit { | |
| 386 | 411 | continue | |
| 387 | 412 | } | |
| @@ -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 "", err | 343 | 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 "", err | 390 | 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. The | 407 | // Preserve every cumulative checkpoint that belongs to this prefix. The | ||
| 381 | - | // final two rows are needed to reconstruct LastTurnUsage as a delta; a | 408 | + | // final two rows are needed to reconstruct LastTurnUsage as a delta. |
| 382 | - | // branch containing only the latest row would incorrectly report the | 409 | + | 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 | continue | 411 | 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 | + | } | |||