detached 6d13ad6

feat(session): resume compact handoffs

commit
6d13ad6e438ae2c4ac5484723ecd074fdcf11766
author
brice · · 2026-08-07 06:39 UTC
committer
brice · · 2026-08-07 07:48 UTC
parent
01ec125
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +932 -97
M README.md +2 -2
@@ -407,7 +407,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
407407 - **fast mode** — request the provider's fast tier where supported. It currently uses OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; toggle it with `/fast` or `/settings`; changes apply on the next model call and persist as `fast_mode`. Other providers currently return an unsupported-provider error. Fast mode may cost more and depends on the selected model/account.
408408 - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`.
409409 - **lsp in sub-agents** — allow newly spawned background sub-agents to use the built-in `lsp` tool. Enabled by default; changes persist as `subagent_lsp_enabled` and apply when a child starts.
410-- **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`. Structural unfinished tails and truncated output continue as before. After a successful after-turn threshold compaction, zut may make at most two hidden, bounded recovery continuations when the assistant's visible text appears to promise future work; clear completed text settles normally, and a newer queued user request wins. Manual `/compact` does not silently start work. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. Truncated-output continuation remains a separate priority rule.
410+- **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`. Structural unfinished tails and truncated output continue as before. After a successful after-turn threshold compaction, zut may make at most two hidden, bounded recovery continuations when the assistant's visible text appears to promise future work; clear completed text settles normally, and a newer queued user request wins. An interrupted interactive session retains an outstanding handoff when resumed, including after portable export and import. Manual `/compact` does not silently start work. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. Truncated-output continuation remains a separate priority rule.
411411 - **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.
412412 - **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`.
413413 - **show loaded resources at startup**: list the active Zutfile agent (for `zut 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`.
@@ -424,7 +424,7 @@ Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each ro
424424
425425 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. Manual `/compact` only summarizes; it does not silently start work afterward. Use it when the context meter creeps past ~80%.
426426
427-zut also auto-compacts in the background after an interactive turn reaches the configured context threshold. Structural unfinished tails and output stopped at the model's token limit continue as before. After a successful after-turn threshold compaction, a visible assistant status that appears to promise future work may receive at most two hidden, bounded recovery continuations; clear completed text settles normally, and a newer queued user request supersedes that recovery. Manual `/compact` does not silently start work, and truncated-output continuation remains a separate priority rule. 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.
427+zut also auto-compacts in the background after an interactive turn reaches the configured context threshold. Structural unfinished tails and output stopped at the model's token limit continue as before. After a successful after-turn threshold compaction, a visible assistant status that appears to promise future work may receive at most two hidden, bounded recovery continuations; clear completed text settles normally, and a newer queued user request supersedes that recovery. An interrupted interactive session resumes an outstanding hidden handoff without displaying it as a chat message. Manual `/compact` does not silently start work, and truncated-output continuation remains a separate priority rule. 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.
428428
429429 ### `/jail`
430430
@@ -407,7 +407,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
407 - **fast mode** — request the provider's fast tier where supported. It currently uses OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; toggle it with `/fast` or `/settings`; changes apply on the next model call and persist as `fast_mode`. Other providers currently return an unsupported-provider error. Fast mode may cost more and depends on the selected model/account.407 - **fast mode** — request the provider's fast tier where supported. It currently uses OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; toggle it with `/fast` or `/settings`; changes apply on the next model call and persist as `fast_mode`. Other providers currently return an unsupported-provider error. Fast mode may cost more and depends on the selected model/account.
408 - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`.408 - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`.
409 - **lsp in sub-agents** — allow newly spawned background sub-agents to use the built-in `lsp` tool. Enabled by default; changes persist as `subagent_lsp_enabled` and apply when a child starts.409 - **lsp in sub-agents** — allow newly spawned background sub-agents to use the built-in `lsp` tool. Enabled by default; changes persist as `subagent_lsp_enabled` and apply when a child starts.
410-- **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`. Structural unfinished tails and truncated output continue as before. After a successful after-turn threshold compaction, zut may make at most two hidden, bounded recovery continuations when the assistant's visible text appears to promise future work; clear completed text settles normally, and a newer queued user request wins. Manual `/compact` does not silently start work. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. Truncated-output continuation remains a separate priority rule.410+- **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`. Structural unfinished tails and truncated output continue as before. After a successful after-turn threshold compaction, zut may make at most two hidden, bounded recovery continuations when the assistant's visible text appears to promise future work; clear completed text settles normally, and a newer queued user request wins. An interrupted interactive session retains an outstanding handoff when resumed, including after portable export and import. Manual `/compact` does not silently start work. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. Truncated-output continuation remains a separate priority rule.
411 - **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.411 - **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.
412 - **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`.412 - **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`.
413 - **show loaded resources at startup**: list the active Zutfile agent (for `zut 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`.413 - **show loaded resources at startup**: list the active Zutfile agent (for `zut 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`.
@@ -424,7 +424,7 @@ Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each ro
424 424
425 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. Manual `/compact` only summarizes; it does not silently start work afterward. Use it when the context meter creeps past ~80%.425 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. Manual `/compact` only summarizes; it does not silently start work afterward. Use it when the context meter creeps past ~80%.
426 426
427-zut also auto-compacts in the background after an interactive turn reaches the configured context threshold. Structural unfinished tails and output stopped at the model's token limit continue as before. After a successful after-turn threshold compaction, a visible assistant status that appears to promise future work may receive at most two hidden, bounded recovery continuations; clear completed text settles normally, and a newer queued user request supersedes that recovery. Manual `/compact` does not silently start work, and truncated-output continuation remains a separate priority rule. 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.427+zut also auto-compacts in the background after an interactive turn reaches the configured context threshold. Structural unfinished tails and output stopped at the model's token limit continue as before. After a successful after-turn threshold compaction, a visible assistant status that appears to promise future work may receive at most two hidden, bounded recovery continuations; clear completed text settles normally, and a newer queued user request supersedes that recovery. An interrupted interactive session resumes an outstanding hidden handoff without displaying it as a chat message. Manual `/compact` does not silently start work, and truncated-output continuation remains a separate priority rule. 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.
428 428
429 ### `/jail`429 ### `/jail`
430 430
M packages/agent/cli.go +24 -3
@@ -1748,6 +1748,24 @@ func runInteractive(ctx context.Context, args Args, version string) error {
17481748 extMgr.EmitSessionEvent("session_compacted", session, states)
17491749 }
17501750 }
1751+ persistCompactHandoff := func(state json.RawMessage) error {
1752+ sessionTransitionMu.RLock()
1753+ defer sessionTransitionMu.RUnlock()
1754+ persistMu.Lock()
1755+ defer persistMu.Unlock()
1756+ if sess == nil {
1757+ return nil
1758+ }
1759+ return sess.UpdateCompactHandoff(state)
1760+ }
1761+ currentCompactHandoff := func() json.RawMessage {
1762+ persistMu.Lock()
1763+ defer persistMu.Unlock()
1764+ if sess == nil {
1765+ return nil
1766+ }
1767+ return append(json.RawMessage(nil), sess.Meta.CompactHandoff...)
1768+ }
17511769 persistToolResult := func(_ string, result core.ToolResult) {
17521770 persistMu.Lock()
17531771 defer persistMu.Unlock()
@@ -1870,7 +1888,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
18701888 // writer, so no new message can land on the old file between the
18711889 // two state changes.
18721890 if iv != nil {
1873- iv.ApplySessionAgent(candidate.agent, candidate.provider, candidate.model)
1891+ iv.ApplySessionAgentWithCompactHandoff(candidate.agent, candidate.provider, candidate.model, candidate.session.Meta.CompactHandoff)
18741892 }
18751893 ag = candidate.agent
18761894 } else {
@@ -2242,6 +2260,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
22422260 UpdateInfoChan: updateCh,
22432261 Sandbox: sharedSandbox,
22442262 Agent: ag,
2263+ InitialCompactHandoff: currentCompactHandoff(),
22452264 InitialSessionTitle: func() string {
22462265 if sess == nil || sessionTitlePending {
22472266 return ""
@@ -2315,8 +2334,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
23152334 }
23162335 return out
23172336 },
2318- LoadSession: loadSession,
2319- ChangeCWD: changeCWD,
2337+ LoadSession: loadSession,
2338+ ChangeCWD: changeCWD,
2339+ PersistCompactHandoff: persistCompactHandoff,
2340+ CurrentCompactHandoff: currentCompactHandoff,
23202341 CurrentSessionPath: func() string {
23212342 persistMu.Lock()
23222343 defer persistMu.Unlock()
@@ -1748,6 +1748,24 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1748 extMgr.EmitSessionEvent("session_compacted", session, states)1748 extMgr.EmitSessionEvent("session_compacted", session, states)
1749 }1749 }
1750 }1750 }
1751+ persistCompactHandoff := func(state json.RawMessage) error {
1752+ sessionTransitionMu.RLock()
1753+ defer sessionTransitionMu.RUnlock()
1754+ persistMu.Lock()
1755+ defer persistMu.Unlock()
1756+ if sess == nil {
1757+ return nil
1758+ }
1759+ return sess.UpdateCompactHandoff(state)
1760+ }
1761+ currentCompactHandoff := func() json.RawMessage {
1762+ persistMu.Lock()
1763+ defer persistMu.Unlock()
1764+ if sess == nil {
1765+ return nil
1766+ }
1767+ return append(json.RawMessage(nil), sess.Meta.CompactHandoff...)
1768+ }
1751 persistToolResult := func(_ string, result core.ToolResult) {1769 persistToolResult := func(_ string, result core.ToolResult) {
1752 persistMu.Lock()1770 persistMu.Lock()
1753 defer persistMu.Unlock()1771 defer persistMu.Unlock()
@@ -1870,7 +1888,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1870 // writer, so no new message can land on the old file between the1888 // writer, so no new message can land on the old file between the
1871 // two state changes.1889 // two state changes.
1872 if iv != nil {1890 if iv != nil {
1873- iv.ApplySessionAgent(candidate.agent, candidate.provider, candidate.model)1891+ iv.ApplySessionAgentWithCompactHandoff(candidate.agent, candidate.provider, candidate.model, candidate.session.Meta.CompactHandoff)
1874 }1892 }
1875 ag = candidate.agent1893 ag = candidate.agent
1876 } else {1894 } else {
@@ -2242,6 +2260,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
2242 UpdateInfoChan: updateCh,2260 UpdateInfoChan: updateCh,
2243 Sandbox: sharedSandbox,2261 Sandbox: sharedSandbox,
2244 Agent: ag,2262 Agent: ag,
2263+ InitialCompactHandoff: currentCompactHandoff(),
2245 InitialSessionTitle: func() string {2264 InitialSessionTitle: func() string {
2246 if sess == nil || sessionTitlePending {2265 if sess == nil || sessionTitlePending {
2247 return ""2266 return ""
@@ -2315,8 +2334,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
2315 }2334 }
2316 return out2335 return out
2317 },2336 },
2318- LoadSession: loadSession,2337+ LoadSession: loadSession,
2319- ChangeCWD: changeCWD,2338+ ChangeCWD: changeCWD,
2339+ PersistCompactHandoff: persistCompactHandoff,
2340+ CurrentCompactHandoff: currentCompactHandoff,
2320 CurrentSessionPath: func() string {2341 CurrentSessionPath: func() string {
2321 persistMu.Lock()2342 persistMu.Lock()
2322 defer persistMu.Unlock()2343 defer persistMu.Unlock()
M packages/agent/cli_session_test.go +26 -0
@@ -2,6 +2,7 @@ package agent
22
33 import (
44 "context"
5+ "encoding/json"
56 "errors"
67 "fmt"
78 "os"
@@ -254,6 +255,31 @@ func TestPrepareSessionResumePreservesLegacyMissingMetadata(t *testing.T) {
254255 }
255256 }
256257
258+func TestPrepareSessionResumePreservesCompactHandoffMetadata(t *testing.T) {
259+ path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{})
260+ session, _, err := core.OpenSession(path)
261+ if err != nil {
262+ t.Fatal(err)
263+ }
264+ handoff := json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`)
265+ if err := session.UpdateCompactHandoff(handoff); err != nil {
266+ t.Fatal(err)
267+ }
268+ if err := session.Close(); err != nil {
269+ t.Fatal(err)
270+ }
271+
272+ current := core.NewAgent(nil, "current-model", "", nil)
273+ candidate, err := prepareSessionResume(path, current, "stored-provider", "stored-model", nil)
274+ if err != nil {
275+ t.Fatal(err)
276+ }
277+ defer candidate.session.Close()
278+ if got := string(candidate.session.Meta.CompactHandoff); got != string(handoff) {
279+ t.Fatalf("candidate compact handoff = %q, want %q", got, handoff)
280+ }
281+}
282+
257283 func TestPrepareSessionResumeFailureLeavesCurrentAndCandidateFileUsable(t *testing.T) {
258284 old := core.NewAgent(nil, "old-model", "", nil)
259285 old.SetMessages([]provider.Message{{
@@ -2,6 +2,7 @@ package agent
2 2
3 import (3 import (
4 "context"4 "context"
5+ "encoding/json"
5 "errors"6 "errors"
6 "fmt"7 "fmt"
7 "os"8 "os"
@@ -254,6 +255,31 @@ func TestPrepareSessionResumePreservesLegacyMissingMetadata(t *testing.T) {
254 }255 }
255 }256 }
256 257
258+func TestPrepareSessionResumePreservesCompactHandoffMetadata(t *testing.T) {
259+ path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{})
260+ session, _, err := core.OpenSession(path)
261+ if err != nil {
262+ t.Fatal(err)
263+ }
264+ handoff := json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`)
265+ if err := session.UpdateCompactHandoff(handoff); err != nil {
266+ t.Fatal(err)
267+ }
268+ if err := session.Close(); err != nil {
269+ t.Fatal(err)
270+ }
271+
272+ current := core.NewAgent(nil, "current-model", "", nil)
273+ candidate, err := prepareSessionResume(path, current, "stored-provider", "stored-model", nil)
274+ if err != nil {
275+ t.Fatal(err)
276+ }
277+ defer candidate.session.Close()
278+ if got := string(candidate.session.Meta.CompactHandoff); got != string(handoff) {
279+ t.Fatalf("candidate compact handoff = %q, want %q", got, handoff)
280+ }
281+}
282+
257 func TestPrepareSessionResumeFailureLeavesCurrentAndCandidateFileUsable(t *testing.T) {283 func TestPrepareSessionResumeFailureLeavesCurrentAndCandidateFileUsable(t *testing.T) {
258 old := core.NewAgent(nil, "old-model", "", nil)284 old := core.NewAgent(nil, "old-model", "", nil)
259 old.SetMessages([]provider.Message{{285 old.SetMessages([]provider.Message{{
M packages/agent/modes/auto_compact_test.go +69 -0
@@ -1,8 +1,11 @@
11 package modes
22
33 import (
4+ "context"
5+ "encoding/json"
46 "errors"
57 "testing"
8+ "time"
69
710 "github.com/bnema/zut/packages/provider"
811 )
@@ -233,6 +236,72 @@ func TestAutoCompactContinuationIsHiddenFromTranscriptButPersisted(t *testing.T)
233236 }
234237 }
235238
239+func TestNewInteractiveRestoresValidCompactHandoff(t *testing.T) {
240+ interactive := NewInteractive(InteractiveConfig{
241+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
242+ })
243+ if got, want := interactive.compactContinuation, (compactContinuationState{reason: compactContinuationStatusRescue, rescueAttempts: 1}); got != want {
244+ t.Fatalf("restored compact handoff = %#v, want %#v", got, want)
245+ }
246+}
247+
248+func TestCancelTurnClearsPersistedCompactHandoff(t *testing.T) {
249+ persisted := make(chan string, 1)
250+ interactive := NewInteractive(InteractiveConfig{
251+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
252+ PersistCompactHandoff: func(state json.RawMessage) error {
253+ persisted <- string(state)
254+ return nil
255+ },
256+ })
257+ _, cancel := context.WithCancel(context.Background())
258+ interactive.mu.Lock()
259+ interactive.busy = true
260+ interactive.cancelTurn = cancel
261+ interactive.mu.Unlock()
262+
263+ interactive.CancelTurn()
264+ select {
265+ case state := <-persisted:
266+ if state != "" {
267+ t.Fatalf("cleared compact handoff = %q, want empty", state)
268+ }
269+ case <-time.After(2 * time.Second):
270+ t.Fatal("cancellation did not clear the persisted compact handoff")
271+ }
272+}
273+
274+func TestDecodeCompactHandoff(t *testing.T) {
275+ tests := []struct {
276+ name string
277+ raw string
278+ want compactContinuationState
279+ }{
280+ {
281+ name: "status rescue",
282+ raw: `{"version":1,"reason":"status_rescue","rescue_attempts":1}`,
283+ want: compactContinuationState{reason: compactContinuationStatusRescue, rescueAttempts: 1},
284+ },
285+ {
286+ name: "forced length",
287+ raw: `{"version":1,"reason":"forced_length"}`,
288+ want: compactContinuationState{reason: compactContinuationForcedLength},
289+ },
290+ {
291+ name: "invalid status attempt", raw: `{"version":1,"reason":"status_rescue","rescue_attempts":3}`},
292+ {name: "unknown reason", raw: `{"version":1,"reason":"other"}`},
293+ {name: "invalid JSON", raw: `{`},
294+ {name: "missing", raw: ""},
295+ }
296+ for _, tt := range tests {
297+ t.Run(tt.name, func(t *testing.T) {
298+ if got := decodeCompactHandoff([]byte(tt.raw)); got != tt.want {
299+ t.Fatalf("decodeCompactHandoff(%q) = %#v, want %#v", tt.raw, got, tt.want)
300+ }
301+ })
302+ }
303+}
304+
236305 func TestShouldAutoCompactUsesConfiguredThreshold(t *testing.T) {
237306 tests := []struct {
238307 name string
@@ -1,8 +1,11 @@
1 package modes1 package modes
2 2
3 import (3 import (
4+ "context"
5+ "encoding/json"
4 "errors"6 "errors"
5 "testing"7 "testing"
8+ "time"
6 9
7 "github.com/bnema/zut/packages/provider"10 "github.com/bnema/zut/packages/provider"
8 )11 )
@@ -233,6 +236,72 @@ func TestAutoCompactContinuationIsHiddenFromTranscriptButPersisted(t *testing.T)
233 }236 }
234 }237 }
235 238
239+func TestNewInteractiveRestoresValidCompactHandoff(t *testing.T) {
240+ interactive := NewInteractive(InteractiveConfig{
241+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
242+ })
243+ if got, want := interactive.compactContinuation, (compactContinuationState{reason: compactContinuationStatusRescue, rescueAttempts: 1}); got != want {
244+ t.Fatalf("restored compact handoff = %#v, want %#v", got, want)
245+ }
246+}
247+
248+func TestCancelTurnClearsPersistedCompactHandoff(t *testing.T) {
249+ persisted := make(chan string, 1)
250+ interactive := NewInteractive(InteractiveConfig{
251+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
252+ PersistCompactHandoff: func(state json.RawMessage) error {
253+ persisted <- string(state)
254+ return nil
255+ },
256+ })
257+ _, cancel := context.WithCancel(context.Background())
258+ interactive.mu.Lock()
259+ interactive.busy = true
260+ interactive.cancelTurn = cancel
261+ interactive.mu.Unlock()
262+
263+ interactive.CancelTurn()
264+ select {
265+ case state := <-persisted:
266+ if state != "" {
267+ t.Fatalf("cleared compact handoff = %q, want empty", state)
268+ }
269+ case <-time.After(2 * time.Second):
270+ t.Fatal("cancellation did not clear the persisted compact handoff")
271+ }
272+}
273+
274+func TestDecodeCompactHandoff(t *testing.T) {
275+ tests := []struct {
276+ name string
277+ raw string
278+ want compactContinuationState
279+ }{
280+ {
281+ name: "status rescue",
282+ raw: `{"version":1,"reason":"status_rescue","rescue_attempts":1}`,
283+ want: compactContinuationState{reason: compactContinuationStatusRescue, rescueAttempts: 1},
284+ },
285+ {
286+ name: "forced length",
287+ raw: `{"version":1,"reason":"forced_length"}`,
288+ want: compactContinuationState{reason: compactContinuationForcedLength},
289+ },
290+ {
291+ name: "invalid status attempt", raw: `{"version":1,"reason":"status_rescue","rescue_attempts":3}`},
292+ {name: "unknown reason", raw: `{"version":1,"reason":"other"}`},
293+ {name: "invalid JSON", raw: `{`},
294+ {name: "missing", raw: ""},
295+ }
296+ for _, tt := range tests {
297+ t.Run(tt.name, func(t *testing.T) {
298+ if got := decodeCompactHandoff([]byte(tt.raw)); got != tt.want {
299+ t.Fatalf("decodeCompactHandoff(%q) = %#v, want %#v", tt.raw, got, tt.want)
300+ }
301+ })
302+ }
303+}
304+
236 func TestShouldAutoCompactUsesConfiguredThreshold(t *testing.T) {305 func TestShouldAutoCompactUsesConfiguredThreshold(t *testing.T) {
237 tests := []struct {306 tests := []struct {
238 name string307 name string
M packages/agent/modes/compact_queue_test.go +209 -0
@@ -2,6 +2,7 @@ package modes
22
33 import (
44 "context"
5+ "encoding/json"
56 "errors"
67 "sync"
78 "testing"
@@ -257,6 +258,199 @@ func (c *thresholdAutoCompactClient) Stream(ctx context.Context, req provider.Re
257258 return out, nil
258259 }
259260
261+type restoredHandoffClient struct {
262+ requests chan provider.Request
263+ replies []string
264+
265+ mu sync.Mutex
266+ calls int
267+}
268+
269+func (c *restoredHandoffClient) Name() string { return "restored-handoff-test" }
270+
271+func (c *restoredHandoffClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
272+ c.mu.Lock()
273+ call := c.calls
274+ c.calls++
275+ text := "done"
276+ if call < len(c.replies) {
277+ text = c.replies[call]
278+ }
279+ c.mu.Unlock()
280+
281+ out := make(chan provider.Event, 1)
282+ go func() {
283+ defer close(out)
284+ c.requests <- req
285+ out <- provider.EventDone{
286+ Stop: provider.StopEnd,
287+ Message: provider.Message{
288+ Role: provider.RoleAssistant,
289+ Content: []provider.Content{provider.TextBlock{Text: text}},
290+ },
291+ }
292+ }()
293+ return out, nil
294+}
295+
296+func TestRestoredStatusHandoffContinuesExistingPromptWithoutDuplication(t *testing.T) {
297+ client := &restoredHandoffClient{
298+ requests: make(chan provider.Request, 2),
299+ replies: []string{"Next I will run the targeted tests.", "All required work is complete."},
300+ }
301+ agent := core.NewAgent(client, "test-model", "", nil)
302+ agent.SetMessages([]provider.Message{{
303+ Role: provider.RoleUser,
304+ Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
305+ Meta: map[string]string{autoCompactContinueMetaKey: "true"},
306+ }})
307+ interactive := NewInteractive(InteractiveConfig{
308+ Agent: agent,
309+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
310+ })
311+ interactive.runCtx = context.Background()
312+
313+ interactive.startRestoredCompactHandoff(context.Background())
314+ for attempt := 1; attempt <= 2; attempt++ {
315+ select {
316+ case req := <-client.requests:
317+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != attempt {
318+ t.Fatalf("request %d continuation prompt count = %d, want %d: %#v", attempt, got, attempt, req.Messages)
319+ }
320+ case <-time.After(2 * time.Second):
321+ t.Fatalf("restored handoff request %d did not start", attempt)
322+ }
323+ }
324+ waitInteractiveIdle(t, interactive)
325+}
326+
327+func TestRestoredHandoffAppendsMissingContinuationPrompt(t *testing.T) {
328+ client := &restoredHandoffClient{requests: make(chan provider.Request, 1)}
329+ agent := core.NewAgent(client, "test-model", "", nil)
330+ interactive := NewInteractive(InteractiveConfig{
331+ Agent: agent,
332+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"structural_tail"}`),
333+ })
334+ interactive.runCtx = context.Background()
335+
336+ interactive.startRestoredCompactHandoff(context.Background())
337+ select {
338+ case req := <-client.requests:
339+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
340+ t.Fatalf("restored request continuation prompt count = %d, want 1: %#v", got, req.Messages)
341+ }
342+ case <-time.After(2 * time.Second):
343+ t.Fatal("restored handoff did not append a missing continuation prompt")
344+ }
345+ waitInteractiveIdle(t, interactive)
346+}
347+
348+func TestRestoredHandoffAfterFinalReplyIsDiscarded(t *testing.T) {
349+ persisted := make(chan string, 1)
350+ client := &restoredHandoffClient{requests: make(chan provider.Request, 1)}
351+ agent := core.NewAgent(client, "test-model", "", nil)
352+ agent.SetMessages([]provider.Message{
353+ {
354+ Role: provider.RoleUser,
355+ Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
356+ Meta: map[string]string{autoCompactContinueMetaKey: "true"},
357+ },
358+ {
359+ Role: provider.RoleAssistant,
360+ Content: []provider.Content{provider.TextBlock{Text: "All required work is complete."}},
361+ },
362+ })
363+ interactive := NewInteractive(InteractiveConfig{
364+ Agent: agent,
365+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
366+ PersistCompactHandoff: func(state json.RawMessage) error {
367+ persisted <- string(state)
368+ return nil
369+ },
370+ })
371+
372+ interactive.startRestoredCompactHandoff(context.Background())
373+ select {
374+ case state := <-persisted:
375+ if state != "" {
376+ t.Fatalf("cleared handoff = %q, want empty", state)
377+ }
378+ case <-time.After(2 * time.Second):
379+ t.Fatal("stale handoff was not cleared")
380+ }
381+ select {
382+ case req := <-client.requests:
383+ t.Fatalf("stale handoff started a provider request: %#v", req.Messages)
384+ default:
385+ }
386+}
387+
388+func TestRestoredHandoffDefersToQueuedUserPrompt(t *testing.T) {
389+ client := &restoredHandoffClient{requests: make(chan provider.Request, 1)}
390+ agent := core.NewAgent(client, "test-model", "", nil)
391+ agent.SetMessages([]provider.Message{{
392+ Role: provider.RoleUser,
393+ Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
394+ Meta: map[string]string{autoCompactContinueMetaKey: "true"},
395+ }})
396+ interactive := NewInteractive(InteractiveConfig{
397+ Agent: agent,
398+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
399+ })
400+ interactive.runCtx = context.Background()
401+ interactive.mu.Lock()
402+ interactive.queued = []string{"newer user request"}
403+ interactive.mu.Unlock()
404+
405+ interactive.startRestoredCompactHandoff(context.Background())
406+ select {
407+ case req := <-client.requests:
408+ if got := requestUserTextCount(req, "newer user request"); got != 1 {
409+ t.Fatalf("newer user request count = %d, want 1: %#v", got, req.Messages)
410+ }
411+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
412+ t.Fatalf("restored continuation prompt count = %d, want only the existing prompt: %#v", got, req.Messages)
413+ }
414+ case <-time.After(2 * time.Second):
415+ t.Fatal("queued user prompt did not run instead of restored handoff")
416+ }
417+ waitInteractiveIdle(t, interactive)
418+}
419+
420+func TestRestoredExhaustedStatusHandoffDoesNotContinueAgain(t *testing.T) {
421+ client := &restoredHandoffClient{
422+ requests: make(chan provider.Request, 2),
423+ replies: []string{"Next I will run the targeted tests."},
424+ }
425+ agent := core.NewAgent(client, "test-model", "", nil)
426+ agent.SetMessages([]provider.Message{{
427+ Role: provider.RoleUser,
428+ Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
429+ Meta: map[string]string{autoCompactContinueMetaKey: "true"},
430+ }})
431+ interactive := NewInteractive(InteractiveConfig{
432+ Agent: agent,
433+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":2}`),
434+ })
435+ interactive.runCtx = context.Background()
436+
437+ interactive.startRestoredCompactHandoff(context.Background())
438+ select {
439+ case req := <-client.requests:
440+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
441+ t.Fatalf("restored request continuation prompt count = %d, want 1: %#v", got, req.Messages)
442+ }
443+ case <-time.After(2 * time.Second):
444+ t.Fatal("restored handoff did not start")
445+ }
446+ waitInteractiveIdle(t, interactive)
447+ select {
448+ case req := <-client.requests:
449+ t.Fatalf("exhausted handoff started another request: %#v", req.Messages)
450+ default:
451+ }
452+}
453+
260454 func TestThresholdAutoCompactionContinuesMostRecentIntent(t *testing.T) {
261455 client := &thresholdAutoCompactClient{
262456 compactionStarted: make(chan struct{}),
@@ -292,6 +486,7 @@ func TestThresholdAutoCompactionContinuesMostRecentIntent(t *testing.T) {
292486 }
293487
294488 func TestThresholdAutoCompactionRescuesStatusAfterCompaction(t *testing.T) {
489+ persistedHandoff := make(chan string, 1)
295490 client := &thresholdAutoCompactClient{
296491 compactionStarted: make(chan struct{}),
297492 releaseCompaction: make(chan struct{}),
@@ -307,6 +502,10 @@ func TestThresholdAutoCompactionRescuesStatusAfterCompaction(t *testing.T) {
307502 Provider: "anthropic",
308503 Model: "claude-sonnet-4-5-20250929",
309504 AutoCompactThreshold: &threshold,
505+ PersistCompactHandoff: func(state json.RawMessage) error {
506+ persistedHandoff <- string(state)
507+ return nil
508+ },
310509 })
311510 interactive.runCtx = context.Background()
312511
@@ -318,6 +517,16 @@ func TestThresholdAutoCompactionRescuesStatusAfterCompaction(t *testing.T) {
318517 }
319518 close(client.releaseCompaction)
320519
520+ select {
521+ case state := <-persistedHandoff:
522+ want := `{"version":1,"reason":"status_rescue","rescue_attempts":1}`
523+ if state != want {
524+ t.Fatalf("persisted handoff = %q, want %q", state, want)
525+ }
526+ case <-time.After(2 * time.Second):
527+ t.Fatal("status rescue handoff was not persisted")
528+ }
529+
321530 select {
322531 case req := <-client.followUpRequest:
323532 if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
@@ -2,6 +2,7 @@ package modes
2 2
3 import (3 import (
4 "context"4 "context"
5+ "encoding/json"
5 "errors"6 "errors"
6 "sync"7 "sync"
7 "testing"8 "testing"
@@ -257,6 +258,199 @@ func (c *thresholdAutoCompactClient) Stream(ctx context.Context, req provider.Re
257 return out, nil258 return out, nil
258 }259 }
259 260
261+type restoredHandoffClient struct {
262+ requests chan provider.Request
263+ replies []string
264+
265+ mu sync.Mutex
266+ calls int
267+}
268+
269+func (c *restoredHandoffClient) Name() string { return "restored-handoff-test" }
270+
271+func (c *restoredHandoffClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
272+ c.mu.Lock()
273+ call := c.calls
274+ c.calls++
275+ text := "done"
276+ if call < len(c.replies) {
277+ text = c.replies[call]
278+ }
279+ c.mu.Unlock()
280+
281+ out := make(chan provider.Event, 1)
282+ go func() {
283+ defer close(out)
284+ c.requests <- req
285+ out <- provider.EventDone{
286+ Stop: provider.StopEnd,
287+ Message: provider.Message{
288+ Role: provider.RoleAssistant,
289+ Content: []provider.Content{provider.TextBlock{Text: text}},
290+ },
291+ }
292+ }()
293+ return out, nil
294+}
295+
296+func TestRestoredStatusHandoffContinuesExistingPromptWithoutDuplication(t *testing.T) {
297+ client := &restoredHandoffClient{
298+ requests: make(chan provider.Request, 2),
299+ replies: []string{"Next I will run the targeted tests.", "All required work is complete."},
300+ }
301+ agent := core.NewAgent(client, "test-model", "", nil)
302+ agent.SetMessages([]provider.Message{{
303+ Role: provider.RoleUser,
304+ Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
305+ Meta: map[string]string{autoCompactContinueMetaKey: "true"},
306+ }})
307+ interactive := NewInteractive(InteractiveConfig{
308+ Agent: agent,
309+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
310+ })
311+ interactive.runCtx = context.Background()
312+
313+ interactive.startRestoredCompactHandoff(context.Background())
314+ for attempt := 1; attempt <= 2; attempt++ {
315+ select {
316+ case req := <-client.requests:
317+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != attempt {
318+ t.Fatalf("request %d continuation prompt count = %d, want %d: %#v", attempt, got, attempt, req.Messages)
319+ }
320+ case <-time.After(2 * time.Second):
321+ t.Fatalf("restored handoff request %d did not start", attempt)
322+ }
323+ }
324+ waitInteractiveIdle(t, interactive)
325+}
326+
327+func TestRestoredHandoffAppendsMissingContinuationPrompt(t *testing.T) {
328+ client := &restoredHandoffClient{requests: make(chan provider.Request, 1)}
329+ agent := core.NewAgent(client, "test-model", "", nil)
330+ interactive := NewInteractive(InteractiveConfig{
331+ Agent: agent,
332+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"structural_tail"}`),
333+ })
334+ interactive.runCtx = context.Background()
335+
336+ interactive.startRestoredCompactHandoff(context.Background())
337+ select {
338+ case req := <-client.requests:
339+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
340+ t.Fatalf("restored request continuation prompt count = %d, want 1: %#v", got, req.Messages)
341+ }
342+ case <-time.After(2 * time.Second):
343+ t.Fatal("restored handoff did not append a missing continuation prompt")
344+ }
345+ waitInteractiveIdle(t, interactive)
346+}
347+
348+func TestRestoredHandoffAfterFinalReplyIsDiscarded(t *testing.T) {
349+ persisted := make(chan string, 1)
350+ client := &restoredHandoffClient{requests: make(chan provider.Request, 1)}
351+ agent := core.NewAgent(client, "test-model", "", nil)
352+ agent.SetMessages([]provider.Message{
353+ {
354+ Role: provider.RoleUser,
355+ Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
356+ Meta: map[string]string{autoCompactContinueMetaKey: "true"},
357+ },
358+ {
359+ Role: provider.RoleAssistant,
360+ Content: []provider.Content{provider.TextBlock{Text: "All required work is complete."}},
361+ },
362+ })
363+ interactive := NewInteractive(InteractiveConfig{
364+ Agent: agent,
365+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
366+ PersistCompactHandoff: func(state json.RawMessage) error {
367+ persisted <- string(state)
368+ return nil
369+ },
370+ })
371+
372+ interactive.startRestoredCompactHandoff(context.Background())
373+ select {
374+ case state := <-persisted:
375+ if state != "" {
376+ t.Fatalf("cleared handoff = %q, want empty", state)
377+ }
378+ case <-time.After(2 * time.Second):
379+ t.Fatal("stale handoff was not cleared")
380+ }
381+ select {
382+ case req := <-client.requests:
383+ t.Fatalf("stale handoff started a provider request: %#v", req.Messages)
384+ default:
385+ }
386+}
387+
388+func TestRestoredHandoffDefersToQueuedUserPrompt(t *testing.T) {
389+ client := &restoredHandoffClient{requests: make(chan provider.Request, 1)}
390+ agent := core.NewAgent(client, "test-model", "", nil)
391+ agent.SetMessages([]provider.Message{{
392+ Role: provider.RoleUser,
393+ Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
394+ Meta: map[string]string{autoCompactContinueMetaKey: "true"},
395+ }})
396+ interactive := NewInteractive(InteractiveConfig{
397+ Agent: agent,
398+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
399+ })
400+ interactive.runCtx = context.Background()
401+ interactive.mu.Lock()
402+ interactive.queued = []string{"newer user request"}
403+ interactive.mu.Unlock()
404+
405+ interactive.startRestoredCompactHandoff(context.Background())
406+ select {
407+ case req := <-client.requests:
408+ if got := requestUserTextCount(req, "newer user request"); got != 1 {
409+ t.Fatalf("newer user request count = %d, want 1: %#v", got, req.Messages)
410+ }
411+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
412+ t.Fatalf("restored continuation prompt count = %d, want only the existing prompt: %#v", got, req.Messages)
413+ }
414+ case <-time.After(2 * time.Second):
415+ t.Fatal("queued user prompt did not run instead of restored handoff")
416+ }
417+ waitInteractiveIdle(t, interactive)
418+}
419+
420+func TestRestoredExhaustedStatusHandoffDoesNotContinueAgain(t *testing.T) {
421+ client := &restoredHandoffClient{
422+ requests: make(chan provider.Request, 2),
423+ replies: []string{"Next I will run the targeted tests."},
424+ }
425+ agent := core.NewAgent(client, "test-model", "", nil)
426+ agent.SetMessages([]provider.Message{{
427+ Role: provider.RoleUser,
428+ Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
429+ Meta: map[string]string{autoCompactContinueMetaKey: "true"},
430+ }})
431+ interactive := NewInteractive(InteractiveConfig{
432+ Agent: agent,
433+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":2}`),
434+ })
435+ interactive.runCtx = context.Background()
436+
437+ interactive.startRestoredCompactHandoff(context.Background())
438+ select {
439+ case req := <-client.requests:
440+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
441+ t.Fatalf("restored request continuation prompt count = %d, want 1: %#v", got, req.Messages)
442+ }
443+ case <-time.After(2 * time.Second):
444+ t.Fatal("restored handoff did not start")
445+ }
446+ waitInteractiveIdle(t, interactive)
447+ select {
448+ case req := <-client.requests:
449+ t.Fatalf("exhausted handoff started another request: %#v", req.Messages)
450+ default:
451+ }
452+}
453+
260 func TestThresholdAutoCompactionContinuesMostRecentIntent(t *testing.T) {454 func TestThresholdAutoCompactionContinuesMostRecentIntent(t *testing.T) {
261 client := &thresholdAutoCompactClient{455 client := &thresholdAutoCompactClient{
262 compactionStarted: make(chan struct{}),456 compactionStarted: make(chan struct{}),
@@ -292,6 +486,7 @@ func TestThresholdAutoCompactionContinuesMostRecentIntent(t *testing.T) {
292 }486 }
293 487
294 func TestThresholdAutoCompactionRescuesStatusAfterCompaction(t *testing.T) {488 func TestThresholdAutoCompactionRescuesStatusAfterCompaction(t *testing.T) {
489+ persistedHandoff := make(chan string, 1)
295 client := &thresholdAutoCompactClient{490 client := &thresholdAutoCompactClient{
296 compactionStarted: make(chan struct{}),491 compactionStarted: make(chan struct{}),
297 releaseCompaction: make(chan struct{}),492 releaseCompaction: make(chan struct{}),
@@ -307,6 +502,10 @@ func TestThresholdAutoCompactionRescuesStatusAfterCompaction(t *testing.T) {
307 Provider: "anthropic",502 Provider: "anthropic",
308 Model: "claude-sonnet-4-5-20250929",503 Model: "claude-sonnet-4-5-20250929",
309 AutoCompactThreshold: &threshold,504 AutoCompactThreshold: &threshold,
505+ PersistCompactHandoff: func(state json.RawMessage) error {
506+ persistedHandoff <- string(state)
507+ return nil
508+ },
310 })509 })
311 interactive.runCtx = context.Background()510 interactive.runCtx = context.Background()
312 511
@@ -318,6 +517,16 @@ func TestThresholdAutoCompactionRescuesStatusAfterCompaction(t *testing.T) {
318 }517 }
319 close(client.releaseCompaction)518 close(client.releaseCompaction)
320 519
520+ select {
521+ case state := <-persistedHandoff:
522+ want := `{"version":1,"reason":"status_rescue","rescue_attempts":1}`
523+ if state != want {
524+ t.Fatalf("persisted handoff = %q, want %q", state, want)
525+ }
526+ case <-time.After(2 * time.Second):
527+ t.Fatal("status rescue handoff was not persisted")
528+ }
529+
321 select {530 select {
322 case req := <-client.followUpRequest:531 case req := <-client.followUpRequest:
323 if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {532 if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
M packages/agent/modes/interactive.go +419 -81
@@ -188,6 +188,16 @@ type InteractiveConfig struct {
188188 // user must /login before they can prompt.
189189 Agent *core.Agent
190190
191+ // InitialCompactHandoff is the opaque, persisted handoff snapshot for a
192+ // resumed interactive session. Invalid data is ignored by this package.
193+ InitialCompactHandoff json.RawMessage
194+ // PersistCompactHandoff records the current opaque handoff snapshot in the
195+ // active session. It must not re-enter Interactive.
196+ PersistCompactHandoff func(json.RawMessage) error
197+ // CurrentCompactHandoff returns the opaque handoff snapshot for the session
198+ // currently owned by the host after a session switch. Invalid data is ignored.
199+ CurrentCompactHandoff func() json.RawMessage
200+
191201 InitialInput string
192202
193203 // StartupPre is auto-submitted once when the interactive session
@@ -526,11 +536,64 @@ const (
526536
527537 const maxStatusRescueContinuations = 2
528538
539+const compactHandoffVersion = 1
540+
529541 type compactContinuationState struct {
530542 reason compactContinuationReason
531543 rescueAttempts int
532544 }
533545
546+type persistedCompactHandoff struct {
547+ Version int `json:"version"`
548+ Reason string `json:"reason"`
549+ RescueAttempts int `json:"rescue_attempts,omitempty"`
550+}
551+
552+func decodeCompactHandoff(raw json.RawMessage) compactContinuationState {
553+ var persisted persistedCompactHandoff
554+ if len(raw) == 0 || json.Unmarshal(raw, &persisted) != nil || persisted.Version != compactHandoffVersion {
555+ return compactContinuationState{}
556+ }
557+ switch persisted.Reason {
558+ case "structural_tail":
559+ if persisted.RescueAttempts == 0 {
560+ return compactContinuationState{reason: compactContinuationStructuralTail}
561+ }
562+ case "forced_length":
563+ if persisted.RescueAttempts == 0 {
564+ return compactContinuationState{reason: compactContinuationForcedLength}
565+ }
566+ case "status_rescue":
567+ if persisted.RescueAttempts >= 1 && persisted.RescueAttempts <= maxStatusRescueContinuations {
568+ return compactContinuationState{reason: compactContinuationStatusRescue, rescueAttempts: persisted.RescueAttempts}
569+ }
570+ }
571+ return compactContinuationState{}
572+}
573+
574+func encodeCompactHandoff(state compactContinuationState) json.RawMessage {
575+ var reason string
576+ switch state.reason {
577+ case compactContinuationStructuralTail:
578+ reason = "structural_tail"
579+ case compactContinuationForcedLength:
580+ reason = "forced_length"
581+ case compactContinuationStatusRescue:
582+ reason = "status_rescue"
583+ default:
584+ return nil
585+ }
586+ encoded, err := json.Marshal(persistedCompactHandoff{
587+ Version: compactHandoffVersion,
588+ Reason: reason,
589+ RescueAttempts: state.rescueAttempts,
590+ })
591+ if err != nil {
592+ return nil
593+ }
594+ return encoded
595+}
596+
534597 type Interactive struct {
535598 cfg InteractiveConfig
536599 view *tui.View
@@ -850,38 +913,39 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
850913 // Prompt is the standard half-block accent bar used by chat
851914 // speaker labels too, so the input gutter matches the rest
852915 // of the UI.
853- ed: tui.NewEditor(cfg.Theme.AccentBar(cfg.Theme.Accent)),
854- rend: renderer,
855- toolCalls: map[string]*tui.ToolCallView{},
856- toolGate: map[string]int{},
857- dirty: make(chan struct{}, 8),
858- modelRefresh: make(chan modelRefreshResult, 1),
859- startupPreDone: make(chan startupPreResult, 1),
860- dialog: newLoginDialog(),
861- modelDialog: newModelDialog(),
862- llamaDialog: newLlamaDialog(),
863- rescueDialog: newRescueDialog(),
864- sessionDialog: newSessionDialog(),
865- subagentsDialog: newSubagentsDialog(),
866- jumpDialog: newJumpDialog(),
867- btwDialog: newBtwDialog(),
868- skillsDialog: newSkillsDialog(),
869- changelogDialog: newChangelogDialog(),
870- confirmDialog: newConfirmDialog(),
871- logoutDialog: newLogoutDialog(),
872- telegramDialog: newTelegramDialog(),
873- settingsDialog: newSettingsDialog(),
874- sessionOpsDialog: newSessionOpsDialog(),
875- sessionTreeDialog: newSessionTreeDialog(),
876- extPanel: newExtPanelDialog(),
877- extStatuses: map[string]map[string]extensionStatus{},
878- extWidgets: map[string]map[string]extensionWidget{},
879- suggest: newSlashSuggester(),
880- fileSuggest: newFileSuggester(),
881- spin: newSpinner(cfg.Theme),
882- inputHistoryIndex: -1,
883- clock: time.Now,
884- reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),
916+ ed: tui.NewEditor(cfg.Theme.AccentBar(cfg.Theme.Accent)),
917+ rend: renderer,
918+ toolCalls: map[string]*tui.ToolCallView{},
919+ toolGate: map[string]int{},
920+ dirty: make(chan struct{}, 8),
921+ modelRefresh: make(chan modelRefreshResult, 1),
922+ startupPreDone: make(chan startupPreResult, 1),
923+ dialog: newLoginDialog(),
924+ modelDialog: newModelDialog(),
925+ llamaDialog: newLlamaDialog(),
926+ rescueDialog: newRescueDialog(),
927+ sessionDialog: newSessionDialog(),
928+ subagentsDialog: newSubagentsDialog(),
929+ jumpDialog: newJumpDialog(),
930+ btwDialog: newBtwDialog(),
931+ skillsDialog: newSkillsDialog(),
932+ changelogDialog: newChangelogDialog(),
933+ confirmDialog: newConfirmDialog(),
934+ logoutDialog: newLogoutDialog(),
935+ telegramDialog: newTelegramDialog(),
936+ settingsDialog: newSettingsDialog(),
937+ sessionOpsDialog: newSessionOpsDialog(),
938+ sessionTreeDialog: newSessionTreeDialog(),
939+ extPanel: newExtPanelDialog(),
940+ extStatuses: map[string]map[string]extensionStatus{},
941+ extWidgets: map[string]map[string]extensionWidget{},
942+ suggest: newSlashSuggester(),
943+ fileSuggest: newFileSuggester(),
944+ spin: newSpinner(cfg.Theme),
945+ inputHistoryIndex: -1,
946+ clock: time.Now,
947+ reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),
948+ compactContinuation: decodeCompactHandoff(cfg.InitialCompactHandoff),
885949 }
886950 i.btwDialog.setCloseHook(func() {
887951 i.confirmDialog.CancelChildConfirmations("side chat closed")
@@ -897,7 +961,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
897961 }
898962 if cfg.Agent != nil {
899963 i.agent = cfg.Agent
900- i.view.Messages = cfg.Agent.Messages()
964+ i.view.Messages = filterHiddenTranscriptMessages(cfg.Agent.Messages())
901965 i.cumUsage = cfg.Agent.Cost()
902966 // Rehydrate the "context used" gauge from the last persisted
903967 // turn. Without this the status bar reads 0.0% after a resume
@@ -992,8 +1056,11 @@ func (i *Interactive) Run(ctx context.Context) error {
9921056 i.Submit(i.cfg.StartupPre)
9931057 } else if i.cfg.AutoSubmitInitial && i.cfg.InitialInput != "" {
9941058 i.Submit(i.cfg.InitialInput)
995- } else if i.cfg.InitialInput != "" {
996- i.ed.SetValue(i.cfg.InitialInput)
1059+ } else {
1060+ if i.cfg.InitialInput != "" {
1061+ i.ed.SetValue(i.cfg.InitialInput)
1062+ }
1063+ i.startRestoredCompactHandoff(ctx)
9971064 }
9981065
9991066 // Stamp the welcome time and schedule a one-shot redraw at the
@@ -3086,10 +3153,15 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
30863153 i.mu.Lock()
30873154 busyCancel := i.busy && i.cancelTurn != nil
30883155 cancelTurn := i.cancelTurn
3156+ var handoff json.RawMessage
3157+ var persistHandoff bool
30893158 if busyCancel {
3090- i.resetCompactContinuationLocked()
3159+ handoff, persistHandoff = i.resetCompactContinuationLocked()
30913160 }
30923161 i.mu.Unlock()
3162+ if persistHandoff {
3163+ i.persistCompactHandoff(handoff)
3164+ }
30933165 if busyCancel {
30943166 cancelTurn()
30953167 // If a confirm dialog is pending, refuse it so the agent
@@ -3403,17 +3475,22 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
34033475 return false
34043476 }
34053477 clearSubmittedInput()
3478+ var handoff json.RawMessage
3479+ var persistHandoff bool
34063480 if ag != nil && !compacting {
34073481 i.mu.Lock()
3408- i.resetCompactContinuationLocked()
3482+ handoff, persistHandoff = i.resetCompactContinuationLocked()
34093483 ag.QueueMessage(text)
34103484 i.mu.Unlock()
34113485 } else {
34123486 i.mu.Lock()
3413- i.resetCompactContinuationLocked()
3487+ handoff, persistHandoff = i.resetCompactContinuationLocked()
34143488 i.queued = append(i.queued, text)
34153489 i.mu.Unlock()
34163490 }
3491+ if persistHandoff {
3492+ i.persistCompactHandoff(handoff)
3493+ }
34173494 i.invalidate()
34183495 return false
34193496 }
@@ -3755,6 +3832,10 @@ func (i *Interactive) applyStartupPreResult(result startupPreResult) {
37553832 // Unlike ApplyChangedCWD it does not mutate cwd-scoped resources or startup
37563833 // context; the session loader is changing the transcript/provider only.
37573834 func (i *Interactive) ApplySessionAgent(ag *core.Agent, providerName, model string) {
3835+ i.ApplySessionAgentWithCompactHandoff(ag, providerName, model, nil)
3836+}
3837+
3838+func (i *Interactive) ApplySessionAgentWithCompactHandoff(ag *core.Agent, providerName, model string, compactHandoff json.RawMessage) {
37583839 if ag == nil {
37593840 return
37603841 }
@@ -3762,6 +3843,7 @@ func (i *Interactive) ApplySessionAgent(ag *core.Agent, providerName, model stri
37623843 defer i.agentMu.Unlock()
37633844 i.mu.Lock()
37643845 i.prepareReplacementAgentLocked(ag)
3846+ i.compactContinuation = decodeCompactHandoff(compactHandoff)
37653847 i.agent = ag
37663848 i.cfg.Provider = providerName
37673849 i.cfg.Model = model
@@ -3819,6 +3901,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin
38193901 defer i.agentMu.Unlock()
38203902 i.mu.Lock()
38213903 i.prepareReplacementAgentLocked(ag)
3904+ i.resetCompactContinuationLocked()
38223905 i.agent = ag
38233906 i.cfg.CWD = cwd
38243907 i.cfg.SubagentsSystemAddendum = subagentsAddendum
@@ -3913,14 +3996,19 @@ func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
39133996 // cannot inspect the agent queue between the busy check and enqueue.
39143997 // Compaction uses the host queue because it has no active agent loop
39153998 // to drain Agent.QueueMessage entries.
3999+ var handoff json.RawMessage
4000+ var persistHandoff bool
39164001 if i.agent != nil && !i.compacting {
3917- i.resetCompactContinuationLocked()
4002+ handoff, persistHandoff = i.resetCompactContinuationLocked()
39184003 i.agent.QueueMessage(text)
39194004 } else {
3920- i.resetCompactContinuationLocked()
4005+ handoff, persistHandoff = i.resetCompactContinuationLocked()
39214006 i.queued = append(i.queued, text)
39224007 }
39234008 i.mu.Unlock()
4009+ if persistHandoff {
4010+ i.persistCompactHandoff(handoff)
4011+ }
39244012 i.invalidate()
39254013 return
39264014 }
@@ -3946,8 +4034,11 @@ func (i *Interactive) CancelTurn() {
39464034 i.mu.Unlock()
39474035 if cancel != nil {
39484036 i.mu.Lock()
3949- i.resetCompactContinuationLocked()
4037+ handoff, persistHandoff := i.resetCompactContinuationLocked()
39504038 i.mu.Unlock()
4039+ if persistHandoff {
4040+ i.persistCompactHandoff(handoff)
4041+ }
39514042 cancel()
39524043 i.confirmDialog.CancelAll("turn cancelled")
39534044 }
@@ -5712,9 +5803,12 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
57125803 i.scrollOffset = 0
57135804 i.extNotes = nil
57145805 i.reloadErrors = nil
5715- i.resetCompactContinuationLocked()
5806+ handoff, persistHandoff := i.resetCompactContinuationLocked()
57165807 i.view.InvalidateRenderCache()
57175808 i.mu.Unlock()
5809+ if persistHandoff {
5810+ i.persistCompactHandoff(handoff)
5811+ }
57185812 case "/help":
57195813 i.mu.Lock()
57205814 i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured)
@@ -5811,17 +5905,22 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
58115905 ag := i.agent
58125906 i.mu.Unlock()
58135907 if busy {
5908+ var handoff json.RawMessage
5909+ var persistHandoff bool
58145910 if ag != nil && !compacting {
58155911 i.mu.Lock()
5816- i.resetCompactContinuationLocked()
5912+ handoff, persistHandoff = i.resetCompactContinuationLocked()
58175913 ag.QueueMessage(studyPrompt)
58185914 i.mu.Unlock()
58195915 } else {
58205916 i.mu.Lock()
5821- i.resetCompactContinuationLocked()
5917+ handoff, persistHandoff = i.resetCompactContinuationLocked()
58225918 i.queued = append(i.queued, studyPrompt)
58235919 i.mu.Unlock()
58245920 }
5921+ if persistHandoff {
5922+ i.persistCompactHandoff(handoff)
5923+ }
58255924 i.invalidate()
58265925 break
58275926 }
@@ -6104,13 +6203,15 @@ func (i *Interactive) doLogout(target string) {
61046203 }
61056204
61066205 i.mu.Lock()
6107- defer i.mu.Unlock()
61086206 i.llamaConfigured = llamaConfigured
61096207 if len(errs) > 0 {
61106208 i.statusErr = "logout errors: " + strings.Join(errs, "; ")
6209+ i.mu.Unlock()
61116210 return
61126211 }
61136212 i.statusErr = ""
6213+ var handoff json.RawMessage
6214+ var persistHandoff bool
61146215 if clearedCurrent {
61156216 // The running agent was using a credential we just wiped. Drop
61166217 // it so prompts can't go out with the stale client, and hint at
@@ -6119,11 +6220,15 @@ func (i *Interactive) doLogout(target string) {
61196220 _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot())
61206221 }
61216222 i.agent = nil
6122- i.resetCompactContinuationLocked()
6223+ handoff, persistHandoff = i.resetCompactContinuationLocked()
61236224 i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
61246225 } else {
61256226 i.statusOK = "logged out of " + strings.Join(providers, ", ")
61266227 }
6228+ i.mu.Unlock()
6229+ if persistHandoff {
6230+ i.persistCompactHandoff(handoff)
6231+ }
61276232 }
61286233
61296234 func providerSetupInfo(provider string) (string, []string, bool) {
@@ -6306,8 +6411,11 @@ func (i *Interactive) cancelAndWaitForIdle() {
63066411 }
63076412 if cancel != nil {
63086413 i.mu.Lock()
6309- i.resetCompactContinuationLocked()
6414+ handoff, persistHandoff := i.resetCompactContinuationLocked()
63106415 i.mu.Unlock()
6416+ if persistHandoff {
6417+ i.persistCompactHandoff(handoff)
6418+ }
63116419 cancel()
63126420 }
63136421 // ConfirmToolCall waits on its response channel rather than the turn
@@ -6371,14 +6479,19 @@ func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) {
63716479 // Keep the mutex held while enqueueing so the turn-completion
63726480 // goroutine cannot publish idle and inspect an empty queue between
63736481 // this check and the append.
6482+ var handoff json.RawMessage
6483+ var persistHandoff bool
63746484 if i.compacting {
6375- i.resetCompactContinuationLocked()
6485+ handoff, persistHandoff = i.resetCompactContinuationLocked()
63766486 i.queued = append(i.queued, prompt)
63776487 } else {
6378- i.resetCompactContinuationLocked()
6488+ handoff, persistHandoff = i.resetCompactContinuationLocked()
63796489 i.agent.QueueMessage(prompt)
63806490 }
63816491 i.mu.Unlock()
6492+ if persistHandoff {
6493+ i.persistCompactHandoff(handoff)
6494+ }
63826495 i.invalidate()
63836496 return
63846497 }
@@ -6525,7 +6638,9 @@ func (i *Interactive) applySessionSelection(path string) {
65256638 return
65266639 }
65276640 i.restoreLoadedSessionTitle()
6641+ state := decodeCompactHandoff(i.currentCompactHandoff())
65286642 i.mu.Lock()
6643+ i.compactContinuation = state
65296644 i.sessionLoading = false
65306645 i.statusOK = "resumed session: " + path
65316646 i.statusErr = ""
@@ -6540,7 +6655,7 @@ func (i *Interactive) applySessionSelection(path string) {
65406655 i.extNotes = nil
65416656 i.view.InvalidateRenderCache()
65426657 if i.agent != nil {
6543- i.view.Messages = i.agent.Messages()
6658+ i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
65446659 i.cumUsage = i.agent.Cost()
65456660 if last := i.agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 {
65466661 i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
@@ -6558,6 +6673,9 @@ func (i *Interactive) applySessionSelection(path string) {
65586673 }
65596674 i.mu.Unlock()
65606675 i.invalidate()
6676+ if state.reason != compactContinuationNone {
6677+ i.startRestoredCompactHandoff(i.runCtx)
6678+ }
65616679 }()
65626680 }
65636681
@@ -6581,25 +6699,30 @@ func (i *Interactive) applyRescueModelSelection(prov, model string) {
65816699 // the supplied builder. rescue=true tags the success message so the
65826700 // user can see that launch-time overrides were ignored.
65836701 func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
6702+ var replaced bool
6703+ swap := func() {
6704+ replaced = i.swapModelUnserialized(prov, model, builder, rescue)
6705+ }
65846706 if i.cfg.SessionTransition != nil {
6585- i.cfg.SessionTransition(func() {
6586- i.swapModelUnserialized(prov, model, builder, rescue)
6587- })
6588- return
6707+ i.cfg.SessionTransition(swap)
6708+ } else {
6709+ swap()
6710+ }
6711+ if replaced {
6712+ i.resetCompactHandoff()
65896713 }
6590- i.swapModelUnserialized(prov, model, builder, rescue)
65916714 }
65926715
6593-func (i *Interactive) swapModelUnserialized(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
6716+func (i *Interactive) swapModelUnserialized(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) bool {
65946717 if model == "" {
6595- return
6718+ return false
65966719 }
65976720 m, err := provider.FindModel(prov, model)
65986721 if err != nil {
65996722 i.mu.Lock()
66006723 i.statusErr = err.Error()
66016724 i.mu.Unlock()
6602- return
6725+ return false
66036726 }
66046727 // Same provider AND not a rescue retry: just swap the model on
66056728 // the existing agent. Mixed-API providers dispatch from model metadata,
@@ -6615,13 +6738,13 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str
66156738 if i.cfg.PersistModel != nil {
66166739 i.cfg.PersistModel(i.cfg.Provider, m.ID)
66176740 }
6618- return
6741+ return false
66196742 }
66206743 if builder == nil {
66216744 i.mu.Lock()
66226745 i.statusErr = "cannot switch provider: no builder configured"
66236746 i.mu.Unlock()
6624- return
6747+ return false
66256748 }
66266749 // Snapshot the current transcript and cumulative usage BEFORE we
66276750 // build the replacement agent so we can hand them off. Without
@@ -6639,7 +6762,7 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str
66396762 i.mu.Lock()
66406763 i.statusErr = err.Error()
66416764 i.mu.Unlock()
6642- return
6765+ return false
66436766 }
66446767
66456768 // Replay the transcript and seed the cost on the freshly-built
@@ -6682,6 +6805,7 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str
66826805 if i.cfg.PersistModel != nil {
66836806 i.cfg.PersistModel(p, md)
66846807 }
6808+ return true
66856809 }
66866810
66876811 func (i *Interactive) handleAuthEvent(ev auth.Event) {
@@ -6697,6 +6821,7 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
66976821 // same transition as settings/session changes. Otherwise a build that
66986822 // started under the old web-search policy could commit afterward.
66996823 var buildErr error
6824+ var clearHandoff bool
67006825 buildAndCommitLogin := func() {
67016826 ag, prov, model, err := i.cfg.BuildAgent()
67026827 if err != nil {
@@ -6707,6 +6832,7 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
67076832 i.mu.Lock()
67086833 oldAgent := i.agent
67096834 i.prepareReplacementAgentLocked(ag)
6835+ _, clearHandoff = i.resetCompactContinuationLocked()
67106836 i.agent = ag
67116837 i.cfg.Provider = prov
67126838 i.cfg.Model = model
@@ -6734,6 +6860,9 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
67346860 i.dialog.ShowResult(false, buildErr.Error())
67356861 return
67366862 }
6863+ if clearHandoff {
6864+ i.persistCompactHandoff(nil)
6865+ }
67376866 i.dialog.ShowResult(true, "")
67386867 }
67396868 }
@@ -6748,14 +6877,168 @@ func (i *Interactive) clearPendingCompactTurnLocked() {
67486877 i.pendingPostCompactNote = ""
67496878 }
67506879
6751-// resetCompactContinuationLocked clears the private handoff state. The
6880+// compactHandoffLocked returns a self-contained persistence snapshot. The
67526881 // caller must hold i.mu.
6753-func (i *Interactive) resetCompactContinuationLocked() {
6754- i.compactContinuation = compactContinuationState{}
6882+func (i *Interactive) compactHandoffLocked() json.RawMessage {
6883+ return encodeCompactHandoff(i.compactContinuation)
6884+}
6885+
6886+// setCompactContinuationLocked updates the private handoff state and returns
6887+// its persistence snapshot plus whether persistence is needed. The caller must
6888+// hold i.mu.
6889+func (i *Interactive) setCompactContinuationLocked(state compactContinuationState) (json.RawMessage, bool) {
6890+ if i.compactContinuation == state {
6891+ return i.compactHandoffLocked(), false
6892+ }
6893+ i.compactContinuation = state
6894+ return i.compactHandoffLocked(), true
6895+}
6896+
6897+// resetCompactContinuationLocked clears the private handoff state and returns
6898+// its persistence snapshot plus whether persistence is needed. The caller must
6899+// hold i.mu.
6900+func (i *Interactive) resetCompactContinuationLocked() (json.RawMessage, bool) {
6901+ return i.setCompactContinuationLocked(compactContinuationState{})
6902+}
6903+
6904+func (i *Interactive) persistCompactHandoff(state json.RawMessage) {
6905+ if persist := i.cfg.PersistCompactHandoff; persist != nil {
6906+ if err := persist(state); err != nil {
6907+ i.ReportError(fmt.Errorf("persist compact handoff: %w", err))
6908+ }
6909+ }
6910+}
6911+
6912+func (i *Interactive) resetCompactHandoff() {
6913+ i.mu.Lock()
6914+ state, persist := i.resetCompactContinuationLocked()
6915+ i.mu.Unlock()
6916+ if persist {
6917+ i.persistCompactHandoff(state)
6918+ }
6919+}
6920+
6921+func (i *Interactive) currentCompactHandoff() json.RawMessage {
6922+ if current := i.cfg.CurrentCompactHandoff; current != nil {
6923+ return current()
6924+ }
6925+ return nil
6926+}
6927+
6928+func (i *Interactive) restoreCompactHandoff(state compactContinuationState) {
6929+ i.mu.Lock()
6930+ i.compactContinuation = state
6931+ i.mu.Unlock()
6932+}
6933+
6934+func (i *Interactive) restoreCurrentCompactHandoff() compactContinuationState {
6935+ state := decodeCompactHandoff(i.currentCompactHandoff())
6936+ i.restoreCompactHandoff(state)
6937+ return state
67556938 }
67566939
67576940 const autoCompactContinuationPrompt = `Context was compacted while work was in progress. Continue the user's most recent active request now; do not wait for them to type "continue". Inspect the context summary and kept recent messages, treating active constraints and preferences there as still in force, including delegation/subagent instructions. If a newer request supersedes earlier plans in the summary, follow the newer request. A progress report, plan update, or statement of future work is not completion: if required work remains, take the next concrete action now. Only finish when the request is actually complete or when a specific user decision is required. If nothing remains, give a brief truthful completion; do not invent work or force a tool call.`
67586941
6942+type compactHandoffResume uint8
6943+
6944+const (
6945+ compactHandoffDiscard compactHandoffResume = iota
6946+ compactHandoffContinueExisting
6947+ compactHandoffAppendPrompt
6948+)
6949+
6950+// classifyCompactHandoffResume validates that the persisted checkpoint still
6951+// describes the effective transcript. A normal assistant reply or a newer
6952+// explicit user message supersedes the checkpoint; tool activity remains an
6953+// unfinished existing turn. No hidden prompt means the process stopped after
6954+// recording the handoff but before appending its context message.
6955+func classifyCompactHandoffResume(messages []provider.Message) compactHandoffResume {
6956+ lastHandoff := -1
6957+ for idx := len(messages) - 1; idx >= 0; idx-- {
6958+ message := messages[idx]
6959+ if message.Role == provider.RoleUser && message.Meta[autoCompactContinueMetaKey] == "true" {
6960+ lastHandoff = idx
6961+ break
6962+ }
6963+ }
6964+ if lastHandoff < 0 {
6965+ return compactHandoffAppendPrompt
6966+ }
6967+ for _, message := range messages[lastHandoff+1:] {
6968+ switch message.Role {
6969+ case provider.RoleTool:
6970+ continue
6971+ case provider.RoleAssistant:
6972+ hasToolCall := false
6973+ for _, content := range message.Content {
6974+ if _, ok := content.(provider.ToolCallBlock); ok {
6975+ hasToolCall = true
6976+ break
6977+ }
6978+ }
6979+ if !hasToolCall {
6980+ return compactHandoffDiscard
6981+ }
6982+ default:
6983+ return compactHandoffDiscard
6984+ }
6985+ }
6986+ return compactHandoffContinueExisting
6987+}
6988+
6989+// startRestoredCompactHandoff resumes a handoff checkpoint restored from the
6990+// active session. When its hidden context message was already persisted, the
6991+// agent continues that existing turn rather than appending it again.
6992+func (i *Interactive) startRestoredCompactHandoff(parent context.Context) {
6993+ if parent == nil {
6994+ parent = i.runCtx
6995+ }
6996+ if parent == nil {
6997+ parent = context.Background()
6998+ }
6999+ i.mu.Lock()
7000+ state := i.compactContinuation
7001+ ag := i.agent
7002+ busy := i.busy || i.compacting || i.autoCompacting || i.shellRunning
7003+ if state.reason == compactContinuationNone || ag == nil || busy {
7004+ i.mu.Unlock()
7005+ return
7006+ }
7007+
7008+ var next string
7009+ continueQueued := false
7010+ var resume compactHandoffResume
7011+ var handoff json.RawMessage
7012+ var persistHandoff bool
7013+ switch {
7014+ case state.reason != compactContinuationForcedLength && len(i.queued) > 0:
7015+ next, i.queued = i.queued[0], i.queued[1:]
7016+ handoff, persistHandoff = i.resetCompactContinuationLocked()
7017+ case state.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0:
7018+ continueQueued = true
7019+ handoff, persistHandoff = i.resetCompactContinuationLocked()
7020+ default:
7021+ resume = classifyCompactHandoffResume(ag.Messages())
7022+ if resume == compactHandoffDiscard {
7023+ handoff, persistHandoff = i.resetCompactContinuationLocked()
7024+ }
7025+ }
7026+ i.mu.Unlock()
7027+ if persistHandoff {
7028+ i.persistCompactHandoff(handoff)
7029+ }
7030+ switch {
7031+ case next != "":
7032+ i.startTurn(parent, next)
7033+ case continueQueued:
7034+ i.startTurnRequest(parent, "", nil, true, false)
7035+ case resume == compactHandoffContinueExisting:
7036+ i.startTurnRequest(parent, "", nil, true, false)
7037+ case resume == compactHandoffAppendPrompt:
7038+ i.startAutoCompactContinuation(parent)
7039+ }
7040+}
7041+
67597042 // startAutoCompactContinuation adds an internal user turn that tells the
67607043 // model to resume the task after threshold compaction. Provider requests
67617044 // cannot continue from an assistant tail on every supported API, so the
@@ -6779,14 +7062,20 @@ func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
67797062 if i.compactContinuation.reason != compactContinuationForcedLength && len(i.queued) > 0 {
67807063 next := i.queued[0]
67817064 i.queued = i.queued[1:]
6782- i.resetCompactContinuationLocked()
7065+ handoff, persistHandoff := i.resetCompactContinuationLocked()
67837066 i.mu.Unlock()
7067+ if persistHandoff {
7068+ i.persistCompactHandoff(handoff)
7069+ }
67847070 i.startTurn(parent, next)
67857071 return
67867072 }
67877073 if i.compactContinuation.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0 {
6788- i.resetCompactContinuationLocked()
7074+ handoff, persistHandoff := i.resetCompactContinuationLocked()
67897075 i.mu.Unlock()
7076+ if persistHandoff {
7077+ i.persistCompactHandoff(handoff)
7078+ }
67907079 i.startTurnRequest(parent, "", nil, true, false)
67917080 return
67927081 }
@@ -6827,8 +7116,10 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
68277116 i.activity.kind = activityCompactingHistory
68287117 }
68297118 i.cancelTurn = cancel
7119+ var initialHandoff json.RawMessage
7120+ persistInitialHandoff := false
68307121 if request.origin == compactOriginManual || request.origin == compactOriginRecovery {
6831- i.resetCompactContinuationLocked()
7122+ initialHandoff, persistInitialHandoff = i.resetCompactContinuationLocked()
68327123 }
68337124 i.statusErr = ""
68347125 i.statusOK = ""
@@ -6838,6 +7129,9 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
68387129 i.scrollOffset = 0
68397130 i.helpBlock = nil
68407131 i.mu.Unlock()
7132+ if persistInitialHandoff {
7133+ i.persistCompactHandoff(initialHandoff)
7134+ }
68417135 i.invalidate()
68427136
68437137 go func() {
@@ -6880,6 +7174,8 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
68807174 var hasNext bool
68817175 var continueExisting bool
68827176 var continueAutomatically bool
7177+ var handoff json.RawMessage
7178+ var persistHandoff bool
68837179
68847180 switch {
68857181 case err != nil && ctx.Err() != nil:
@@ -6891,7 +7187,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
68917187 }
68927188 i.queued = nil // drop queue on cancel
68937189 i.clearPendingCompactTurnLocked()
6894- i.resetCompactContinuationLocked()
7190+ handoff, persistHandoff = i.resetCompactContinuationLocked()
68957191 if i.agent != nil {
68967192 i.agent.DrainQueuedMessages()
68977193 }
@@ -6900,7 +7196,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
69007196 i.statusOK = ""
69017197 i.queued = nil // drop queue on error
69027198 i.clearPendingCompactTurnLocked()
6903- i.resetCompactContinuationLocked()
7199+ handoff, persistHandoff = i.resetCompactContinuationLocked()
69047200 if i.agent != nil {
69057201 i.agent.DrainQueuedMessages()
69067202 }
@@ -6941,12 +7237,12 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
69417237 case continuationReason == compactContinuationForcedLength:
69427238 // Forced truncated-output continuation keeps its existing
69437239 // priority over an explicitly queued prompt.
6944- i.compactContinuation = compactContinuationState{reason: continuationReason}
7240+ handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
69457241 continueAutomatically = true
69467242 case len(i.queued) > 0:
69477243 next, i.queued = i.queued[0], i.queued[1:]
69487244 hasNext = true
6949- i.resetCompactContinuationLocked()
7245+ handoff, persistHandoff = i.resetCompactContinuationLocked()
69507246 case continuationReason == compactContinuationStructuralTail || continuationReason == compactContinuationStatusRescue:
69517247 if continuationReason == compactContinuationStatusRescue {
69527248 attempts := 1
@@ -6954,19 +7250,19 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
69547250 attempts = i.compactContinuation.rescueAttempts + 1
69557251 }
69567252 if attempts > maxStatusRescueContinuations {
6957- i.resetCompactContinuationLocked()
7253+ handoff, persistHandoff = i.resetCompactContinuationLocked()
69587254 break
69597255 }
6960- i.compactContinuation = compactContinuationState{
7256+ handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{
69617257 reason: continuationReason,
69627258 rescueAttempts: attempts,
6963- }
7259+ })
69647260 } else {
6965- i.compactContinuation = compactContinuationState{reason: continuationReason}
7261+ handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
69667262 }
69677263 continueAutomatically = true
69687264 default:
6969- i.resetCompactContinuationLocked()
7265+ handoff, persistHandoff = i.resetCompactContinuationLocked()
69707266 }
69717267 }
69727268 // Keep the host busy until the hand-off decision is committed under
@@ -6977,6 +7273,9 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
69777273 i.compacting = false
69787274 i.autoCompacting = false
69797275 i.mu.Unlock()
7276+ if persistHandoff {
7277+ i.persistCompactHandoff(handoff)
7278+ }
69807279 runPendingIdleWork(pendingIdleWork)
69817280 i.invalidate()
69827281
@@ -7156,8 +7455,10 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
71567455 // The condense flow re-fires the user's queued prompt for us, so
71577456 // we just hand it off and exit.
71587457 i.mu.Lock()
7458+ var resetHandoff json.RawMessage
7459+ var persistResetHandoff bool
71597460 if !continueExisting {
7160- i.resetCompactContinuationLocked()
7461+ resetHandoff, persistResetHandoff = i.resetCompactContinuationLocked()
71617462 }
71627463 needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()
71637464 if needsPreCompact {
@@ -7178,11 +7479,17 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
71787479 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))
71797480 i.pendingPostCompactNote = "context auto-compacted; sending your last message"
71807481 i.mu.Unlock()
7482+ if persistResetHandoff {
7483+ i.persistCompactHandoff(resetHandoff)
7484+ }
71817485 i.invalidate()
71827486 i.runCompact(parent, compactContinuationRequest{origin: compactOriginPreTurnThreshold})
71837487 return
71847488 }
71857489 i.mu.Unlock()
7490+ if persistResetHandoff {
7491+ i.persistCompactHandoff(resetHandoff)
7492+ }
71867493
71877494 ctx, cancel := context.WithCancel(parent)
71887495 i.mu.Lock()
@@ -7349,18 +7656,23 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
73497656 continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil
73507657 shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
73517658 continueStatusRescue := false
7659+ var handoff json.RawMessage
7660+ var persistHandoff bool
73527661 if statusRescueActive && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil {
73537662 followUpMessages := i.agent.Messages()
73547663 reason := classifyCompactionContinuation(compactOriginManual, true, lastStop, lastTurnErr, followUpMessages)
73557664 if reason == compactContinuationStatusRescue && i.compactContinuation.rescueAttempts < maxStatusRescueContinuations {
7356- i.compactContinuation.rescueAttempts++
7665+ handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{
7666+ reason: compactContinuationStatusRescue,
7667+ rescueAttempts: i.compactContinuation.rescueAttempts + 1,
7668+ })
73577669 continueStatusRescue = true
73587670 } else {
7359- i.resetCompactContinuationLocked()
7671+ handoff, persistHandoff = i.resetCompactContinuationLocked()
73607672 }
73617673 }
73627674 if !continueStatusRescue && (ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) {
7363- i.resetCompactContinuationLocked()
7675+ handoff, persistHandoff = i.resetCompactContinuationLocked()
73647676 }
73657677 // The agent run can finish before the paced final text reaches the
73667678 // transcript. A compaction replaces that transcript, so it must never
@@ -7372,6 +7684,9 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
73727684 alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact)
73737685 i.busy = hasNext || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact
73747686 i.mu.Unlock()
7687+ if persistHandoff {
7688+ i.persistCompactHandoff(handoff)
7689+ }
73757690 if alertReason != "" {
73767691 i.scheduleMainAlert(alertReason)
73777692 }
@@ -7866,7 +8181,6 @@ func (i *Interactive) applyAgentPromptConfig(ag *core.Agent, system string, tool
78668181 // prepareReplacementAgentLocked applies session-wide capability policy before
78678182 // ag becomes visible to prompt submission. The caller holds agentMu and i.mu.
78688183 func (i *Interactive) prepareReplacementAgentLocked(ag *core.Agent) {
7869- i.resetCompactContinuationLocked()
78708184 if ag == nil {
78718185 i.setWebSearchAvailable(false)
78728186 return
@@ -8778,11 +9092,27 @@ func (i *Interactive) doSessionImport(src string) {
87789092 return
87799093 }
87809094 i.restoreLoadedSessionTitle()
9095+ state := i.restoreCurrentCompactHandoff()
87819096 i.mu.Lock()
87829097 i.statusOK = "imported and switched to session " + friendlyPath(newPath)
87839098 i.statusErr = ""
9099+ if i.agent != nil {
9100+ i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
9101+ i.cumUsage = i.agent.Cost()
9102+ last := i.agent.LastTurnUsage()
9103+ i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
9104+ if len(i.view.Messages) > initialResumeTailLimit {
9105+ i.view.TailLimit = initialResumeTailLimit
9106+ } else {
9107+ i.view.TailLimit = 0
9108+ }
9109+ i.view.InvalidateRenderCache()
9110+ }
87849111 i.mu.Unlock()
87859112 i.invalidate()
9113+ if state.reason != compactContinuationNone {
9114+ i.startRestoredCompactHandoff(i.runCtx)
9115+ }
87869116 }
87879117
87889118 // defaultExportDir returns ~/Downloads when it exists, or ~ as a
@@ -8997,6 +9327,7 @@ func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo in
89979327 i.setSessionTreeError("tree: checkout failed: " + err.Error())
89989328 return
89999329 }
9330+ state := i.restoreCurrentCompactHandoff()
90009331
90019332 // LoadSession is intentionally the old func(string) error callback: the
90029333 // CLI and embedders already own agent/session swapping. Refresh the view
@@ -9032,6 +9363,9 @@ func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo in
90329363 // Submit, startTurn, or queue a message here; the restored user draft is
90339364 // submitted only by a later explicit Enter.
90349365 i.invalidate()
9366+ if state.reason != compactContinuationNone {
9367+ i.startRestoredCompactHandoff(i.runCtx)
9368+ }
90359369 }
90369370
90379371 // applyForkSelection branches the current session at msgIdx+1 (so
@@ -9079,12 +9413,16 @@ func (i *Interactive) applyForkSelection(msgIdx int) {
90799413 i.invalidate()
90809414 return
90819415 }
9416+ state := i.restoreCurrentCompactHandoff()
90829417 i.resetSessionTitleForFreshBranch()
90839418 i.mu.Lock()
90849419 i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)
90859420 i.statusErr = ""
90869421 i.mu.Unlock()
90879422 i.invalidate()
9423+ if state.reason != compactContinuationNone {
9424+ i.startRestoredCompactHandoff(i.runCtx)
9425+ }
90889426 }
90899427
90909428 // formatInt is a tiny strconv.Itoa shim; keeps the handler above
@@ -188,6 +188,16 @@ type InteractiveConfig struct {
188 // user must /login before they can prompt.188 // user must /login before they can prompt.
189 Agent *core.Agent189 Agent *core.Agent
190 190
191+ // InitialCompactHandoff is the opaque, persisted handoff snapshot for a
192+ // resumed interactive session. Invalid data is ignored by this package.
193+ InitialCompactHandoff json.RawMessage
194+ // PersistCompactHandoff records the current opaque handoff snapshot in the
195+ // active session. It must not re-enter Interactive.
196+ PersistCompactHandoff func(json.RawMessage) error
197+ // CurrentCompactHandoff returns the opaque handoff snapshot for the session
198+ // currently owned by the host after a session switch. Invalid data is ignored.
199+ CurrentCompactHandoff func() json.RawMessage
200+
191 InitialInput string201 InitialInput string
192 202
193 // StartupPre is auto-submitted once when the interactive session203 // StartupPre is auto-submitted once when the interactive session
@@ -526,11 +536,64 @@ const (
526 536
527 const maxStatusRescueContinuations = 2537 const maxStatusRescueContinuations = 2
528 538
539+const compactHandoffVersion = 1
540+
529 type compactContinuationState struct {541 type compactContinuationState struct {
530 reason compactContinuationReason542 reason compactContinuationReason
531 rescueAttempts int543 rescueAttempts int
532 }544 }
533 545
546+type persistedCompactHandoff struct {
547+ Version int `json:"version"`
548+ Reason string `json:"reason"`
549+ RescueAttempts int `json:"rescue_attempts,omitempty"`
550+}
551+
552+func decodeCompactHandoff(raw json.RawMessage) compactContinuationState {
553+ var persisted persistedCompactHandoff
554+ if len(raw) == 0 || json.Unmarshal(raw, &persisted) != nil || persisted.Version != compactHandoffVersion {
555+ return compactContinuationState{}
556+ }
557+ switch persisted.Reason {
558+ case "structural_tail":
559+ if persisted.RescueAttempts == 0 {
560+ return compactContinuationState{reason: compactContinuationStructuralTail}
561+ }
562+ case "forced_length":
563+ if persisted.RescueAttempts == 0 {
564+ return compactContinuationState{reason: compactContinuationForcedLength}
565+ }
566+ case "status_rescue":
567+ if persisted.RescueAttempts >= 1 && persisted.RescueAttempts <= maxStatusRescueContinuations {
568+ return compactContinuationState{reason: compactContinuationStatusRescue, rescueAttempts: persisted.RescueAttempts}
569+ }
570+ }
571+ return compactContinuationState{}
572+}
573+
574+func encodeCompactHandoff(state compactContinuationState) json.RawMessage {
575+ var reason string
576+ switch state.reason {
577+ case compactContinuationStructuralTail:
578+ reason = "structural_tail"
579+ case compactContinuationForcedLength:
580+ reason = "forced_length"
581+ case compactContinuationStatusRescue:
582+ reason = "status_rescue"
583+ default:
584+ return nil
585+ }
586+ encoded, err := json.Marshal(persistedCompactHandoff{
587+ Version: compactHandoffVersion,
588+ Reason: reason,
589+ RescueAttempts: state.rescueAttempts,
590+ })
591+ if err != nil {
592+ return nil
593+ }
594+ return encoded
595+}
596+
534 type Interactive struct {597 type Interactive struct {
535 cfg InteractiveConfig598 cfg InteractiveConfig
536 view *tui.View599 view *tui.View
@@ -850,38 +913,39 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
850 // Prompt is the standard half-block accent bar used by chat913 // Prompt is the standard half-block accent bar used by chat
851 // speaker labels too, so the input gutter matches the rest914 // speaker labels too, so the input gutter matches the rest
852 // of the UI.915 // of the UI.
853- ed: tui.NewEditor(cfg.Theme.AccentBar(cfg.Theme.Accent)),916+ ed: tui.NewEditor(cfg.Theme.AccentBar(cfg.Theme.Accent)),
854- rend: renderer,917+ rend: renderer,
855- toolCalls: map[string]*tui.ToolCallView{},918+ toolCalls: map[string]*tui.ToolCallView{},
856- toolGate: map[string]int{},919+ toolGate: map[string]int{},
857- dirty: make(chan struct{}, 8),920+ dirty: make(chan struct{}, 8),
858- modelRefresh: make(chan modelRefreshResult, 1),921+ modelRefresh: make(chan modelRefreshResult, 1),
859- startupPreDone: make(chan startupPreResult, 1),922+ startupPreDone: make(chan startupPreResult, 1),
860- dialog: newLoginDialog(),923+ dialog: newLoginDialog(),
861- modelDialog: newModelDialog(),924+ modelDialog: newModelDialog(),
862- llamaDialog: newLlamaDialog(),925+ llamaDialog: newLlamaDialog(),
863- rescueDialog: newRescueDialog(),926+ rescueDialog: newRescueDialog(),
864- sessionDialog: newSessionDialog(),927+ sessionDialog: newSessionDialog(),
865- subagentsDialog: newSubagentsDialog(),928+ subagentsDialog: newSubagentsDialog(),
866- jumpDialog: newJumpDialog(),929+ jumpDialog: newJumpDialog(),
867- btwDialog: newBtwDialog(),930+ btwDialog: newBtwDialog(),
868- skillsDialog: newSkillsDialog(),931+ skillsDialog: newSkillsDialog(),
869- changelogDialog: newChangelogDialog(),932+ changelogDialog: newChangelogDialog(),
870- confirmDialog: newConfirmDialog(),933+ confirmDialog: newConfirmDialog(),
871- logoutDialog: newLogoutDialog(),934+ logoutDialog: newLogoutDialog(),
872- telegramDialog: newTelegramDialog(),935+ telegramDialog: newTelegramDialog(),
873- settingsDialog: newSettingsDialog(),936+ settingsDialog: newSettingsDialog(),
874- sessionOpsDialog: newSessionOpsDialog(),937+ sessionOpsDialog: newSessionOpsDialog(),
875- sessionTreeDialog: newSessionTreeDialog(),938+ sessionTreeDialog: newSessionTreeDialog(),
876- extPanel: newExtPanelDialog(),939+ extPanel: newExtPanelDialog(),
877- extStatuses: map[string]map[string]extensionStatus{},940+ extStatuses: map[string]map[string]extensionStatus{},
878- extWidgets: map[string]map[string]extensionWidget{},941+ extWidgets: map[string]map[string]extensionWidget{},
879- suggest: newSlashSuggester(),942+ suggest: newSlashSuggester(),
880- fileSuggest: newFileSuggester(),943+ fileSuggest: newFileSuggester(),
881- spin: newSpinner(cfg.Theme),944+ spin: newSpinner(cfg.Theme),
882- inputHistoryIndex: -1,945+ inputHistoryIndex: -1,
883- clock: time.Now,946+ clock: time.Now,
884- reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),947+ reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),
948+ compactContinuation: decodeCompactHandoff(cfg.InitialCompactHandoff),
885 }949 }
886 i.btwDialog.setCloseHook(func() {950 i.btwDialog.setCloseHook(func() {
887 i.confirmDialog.CancelChildConfirmations("side chat closed")951 i.confirmDialog.CancelChildConfirmations("side chat closed")
@@ -897,7 +961,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
897 }961 }
898 if cfg.Agent != nil {962 if cfg.Agent != nil {
899 i.agent = cfg.Agent963 i.agent = cfg.Agent
900- i.view.Messages = cfg.Agent.Messages()964+ i.view.Messages = filterHiddenTranscriptMessages(cfg.Agent.Messages())
901 i.cumUsage = cfg.Agent.Cost()965 i.cumUsage = cfg.Agent.Cost()
902 // Rehydrate the "context used" gauge from the last persisted966 // Rehydrate the "context used" gauge from the last persisted
903 // turn. Without this the status bar reads 0.0% after a resume967 // turn. Without this the status bar reads 0.0% after a resume
@@ -992,8 +1056,11 @@ func (i *Interactive) Run(ctx context.Context) error {
992 i.Submit(i.cfg.StartupPre)1056 i.Submit(i.cfg.StartupPre)
993 } else if i.cfg.AutoSubmitInitial && i.cfg.InitialInput != "" {1057 } else if i.cfg.AutoSubmitInitial && i.cfg.InitialInput != "" {
994 i.Submit(i.cfg.InitialInput)1058 i.Submit(i.cfg.InitialInput)
995- } else if i.cfg.InitialInput != "" {1059+ } else {
996- i.ed.SetValue(i.cfg.InitialInput)1060+ if i.cfg.InitialInput != "" {
1061+ i.ed.SetValue(i.cfg.InitialInput)
1062+ }
1063+ i.startRestoredCompactHandoff(ctx)
997 }1064 }
998 1065
999 // Stamp the welcome time and schedule a one-shot redraw at the1066 // Stamp the welcome time and schedule a one-shot redraw at the
@@ -3086,10 +3153,15 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
3086 i.mu.Lock()3153 i.mu.Lock()
3087 busyCancel := i.busy && i.cancelTurn != nil3154 busyCancel := i.busy && i.cancelTurn != nil
3088 cancelTurn := i.cancelTurn3155 cancelTurn := i.cancelTurn
3156+ var handoff json.RawMessage
3157+ var persistHandoff bool
3089 if busyCancel {3158 if busyCancel {
3090- i.resetCompactContinuationLocked()3159+ handoff, persistHandoff = i.resetCompactContinuationLocked()
3091 }3160 }
3092 i.mu.Unlock()3161 i.mu.Unlock()
3162+ if persistHandoff {
3163+ i.persistCompactHandoff(handoff)
3164+ }
3093 if busyCancel {3165 if busyCancel {
3094 cancelTurn()3166 cancelTurn()
3095 // If a confirm dialog is pending, refuse it so the agent3167 // If a confirm dialog is pending, refuse it so the agent
@@ -3403,17 +3475,22 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
3403 return false3475 return false
3404 }3476 }
3405 clearSubmittedInput()3477 clearSubmittedInput()
3478+ var handoff json.RawMessage
3479+ var persistHandoff bool
3406 if ag != nil && !compacting {3480 if ag != nil && !compacting {
3407 i.mu.Lock()3481 i.mu.Lock()
3408- i.resetCompactContinuationLocked()3482+ handoff, persistHandoff = i.resetCompactContinuationLocked()
3409 ag.QueueMessage(text)3483 ag.QueueMessage(text)
3410 i.mu.Unlock()3484 i.mu.Unlock()
3411 } else {3485 } else {
3412 i.mu.Lock()3486 i.mu.Lock()
3413- i.resetCompactContinuationLocked()3487+ handoff, persistHandoff = i.resetCompactContinuationLocked()
3414 i.queued = append(i.queued, text)3488 i.queued = append(i.queued, text)
3415 i.mu.Unlock()3489 i.mu.Unlock()
3416 }3490 }
3491+ if persistHandoff {
3492+ i.persistCompactHandoff(handoff)
3493+ }
3417 i.invalidate()3494 i.invalidate()
3418 return false3495 return false
3419 }3496 }
@@ -3755,6 +3832,10 @@ func (i *Interactive) applyStartupPreResult(result startupPreResult) {
3755 // Unlike ApplyChangedCWD it does not mutate cwd-scoped resources or startup3832 // Unlike ApplyChangedCWD it does not mutate cwd-scoped resources or startup
3756 // context; the session loader is changing the transcript/provider only.3833 // context; the session loader is changing the transcript/provider only.
3757 func (i *Interactive) ApplySessionAgent(ag *core.Agent, providerName, model string) {3834 func (i *Interactive) ApplySessionAgent(ag *core.Agent, providerName, model string) {
3835+ i.ApplySessionAgentWithCompactHandoff(ag, providerName, model, nil)
3836+}
3837+
3838+func (i *Interactive) ApplySessionAgentWithCompactHandoff(ag *core.Agent, providerName, model string, compactHandoff json.RawMessage) {
3758 if ag == nil {3839 if ag == nil {
3759 return3840 return
3760 }3841 }
@@ -3762,6 +3843,7 @@ func (i *Interactive) ApplySessionAgent(ag *core.Agent, providerName, model stri
3762 defer i.agentMu.Unlock()3843 defer i.agentMu.Unlock()
3763 i.mu.Lock()3844 i.mu.Lock()
3764 i.prepareReplacementAgentLocked(ag)3845 i.prepareReplacementAgentLocked(ag)
3846+ i.compactContinuation = decodeCompactHandoff(compactHandoff)
3765 i.agent = ag3847 i.agent = ag
3766 i.cfg.Provider = providerName3848 i.cfg.Provider = providerName
3767 i.cfg.Model = model3849 i.cfg.Model = model
@@ -3819,6 +3901,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin
3819 defer i.agentMu.Unlock()3901 defer i.agentMu.Unlock()
3820 i.mu.Lock()3902 i.mu.Lock()
3821 i.prepareReplacementAgentLocked(ag)3903 i.prepareReplacementAgentLocked(ag)
3904+ i.resetCompactContinuationLocked()
3822 i.agent = ag3905 i.agent = ag
3823 i.cfg.CWD = cwd3906 i.cfg.CWD = cwd
3824 i.cfg.SubagentsSystemAddendum = subagentsAddendum3907 i.cfg.SubagentsSystemAddendum = subagentsAddendum
@@ -3913,14 +3996,19 @@ func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
3913 // cannot inspect the agent queue between the busy check and enqueue.3996 // cannot inspect the agent queue between the busy check and enqueue.
3914 // Compaction uses the host queue because it has no active agent loop3997 // Compaction uses the host queue because it has no active agent loop
3915 // to drain Agent.QueueMessage entries.3998 // to drain Agent.QueueMessage entries.
3999+ var handoff json.RawMessage
4000+ var persistHandoff bool
3916 if i.agent != nil && !i.compacting {4001 if i.agent != nil && !i.compacting {
3917- i.resetCompactContinuationLocked()4002+ handoff, persistHandoff = i.resetCompactContinuationLocked()
3918 i.agent.QueueMessage(text)4003 i.agent.QueueMessage(text)
3919 } else {4004 } else {
3920- i.resetCompactContinuationLocked()4005+ handoff, persistHandoff = i.resetCompactContinuationLocked()
3921 i.queued = append(i.queued, text)4006 i.queued = append(i.queued, text)
3922 }4007 }
3923 i.mu.Unlock()4008 i.mu.Unlock()
4009+ if persistHandoff {
4010+ i.persistCompactHandoff(handoff)
4011+ }
3924 i.invalidate()4012 i.invalidate()
3925 return4013 return
3926 }4014 }
@@ -3946,8 +4034,11 @@ func (i *Interactive) CancelTurn() {
3946 i.mu.Unlock()4034 i.mu.Unlock()
3947 if cancel != nil {4035 if cancel != nil {
3948 i.mu.Lock()4036 i.mu.Lock()
3949- i.resetCompactContinuationLocked()4037+ handoff, persistHandoff := i.resetCompactContinuationLocked()
3950 i.mu.Unlock()4038 i.mu.Unlock()
4039+ if persistHandoff {
4040+ i.persistCompactHandoff(handoff)
4041+ }
3951 cancel()4042 cancel()
3952 i.confirmDialog.CancelAll("turn cancelled")4043 i.confirmDialog.CancelAll("turn cancelled")
3953 }4044 }
@@ -5712,9 +5803,12 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
5712 i.scrollOffset = 05803 i.scrollOffset = 0
5713 i.extNotes = nil5804 i.extNotes = nil
5714 i.reloadErrors = nil5805 i.reloadErrors = nil
5715- i.resetCompactContinuationLocked()5806+ handoff, persistHandoff := i.resetCompactContinuationLocked()
5716 i.view.InvalidateRenderCache()5807 i.view.InvalidateRenderCache()
5717 i.mu.Unlock()5808 i.mu.Unlock()
5809+ if persistHandoff {
5810+ i.persistCompactHandoff(handoff)
5811+ }
5718 case "/help":5812 case "/help":
5719 i.mu.Lock()5813 i.mu.Lock()
5720 i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured)5814 i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured)
@@ -5811,17 +5905,22 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
5811 ag := i.agent5905 ag := i.agent
5812 i.mu.Unlock()5906 i.mu.Unlock()
5813 if busy {5907 if busy {
5908+ var handoff json.RawMessage
5909+ var persistHandoff bool
5814 if ag != nil && !compacting {5910 if ag != nil && !compacting {
5815 i.mu.Lock()5911 i.mu.Lock()
5816- i.resetCompactContinuationLocked()5912+ handoff, persistHandoff = i.resetCompactContinuationLocked()
5817 ag.QueueMessage(studyPrompt)5913 ag.QueueMessage(studyPrompt)
5818 i.mu.Unlock()5914 i.mu.Unlock()
5819 } else {5915 } else {
5820 i.mu.Lock()5916 i.mu.Lock()
5821- i.resetCompactContinuationLocked()5917+ handoff, persistHandoff = i.resetCompactContinuationLocked()
5822 i.queued = append(i.queued, studyPrompt)5918 i.queued = append(i.queued, studyPrompt)
5823 i.mu.Unlock()5919 i.mu.Unlock()
5824 }5920 }
5921+ if persistHandoff {
5922+ i.persistCompactHandoff(handoff)
5923+ }
5825 i.invalidate()5924 i.invalidate()
5826 break5925 break
5827 }5926 }
@@ -6104,13 +6203,15 @@ func (i *Interactive) doLogout(target string) {
6104 }6203 }
6105 6204
6106 i.mu.Lock()6205 i.mu.Lock()
6107- defer i.mu.Unlock()
6108 i.llamaConfigured = llamaConfigured6206 i.llamaConfigured = llamaConfigured
6109 if len(errs) > 0 {6207 if len(errs) > 0 {
6110 i.statusErr = "logout errors: " + strings.Join(errs, "; ")6208 i.statusErr = "logout errors: " + strings.Join(errs, "; ")
6209+ i.mu.Unlock()
6111 return6210 return
6112 }6211 }
6113 i.statusErr = ""6212 i.statusErr = ""
6213+ var handoff json.RawMessage
6214+ var persistHandoff bool
6114 if clearedCurrent {6215 if clearedCurrent {
6115 // The running agent was using a credential we just wiped. Drop6216 // The running agent was using a credential we just wiped. Drop
6116 // it so prompts can't go out with the stale client, and hint at6217 // it so prompts can't go out with the stale client, and hint at
@@ -6119,11 +6220,15 @@ func (i *Interactive) doLogout(target string) {
6119 _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot())6220 _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot())
6120 }6221 }
6121 i.agent = nil6222 i.agent = nil
6122- i.resetCompactContinuationLocked()6223+ handoff, persistHandoff = i.resetCompactContinuationLocked()
6123 i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."6224 i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
6124 } else {6225 } else {
6125 i.statusOK = "logged out of " + strings.Join(providers, ", ")6226 i.statusOK = "logged out of " + strings.Join(providers, ", ")
6126 }6227 }
6228+ i.mu.Unlock()
6229+ if persistHandoff {
6230+ i.persistCompactHandoff(handoff)
6231+ }
6127 }6232 }
6128 6233
6129 func providerSetupInfo(provider string) (string, []string, bool) {6234 func providerSetupInfo(provider string) (string, []string, bool) {
@@ -6306,8 +6411,11 @@ func (i *Interactive) cancelAndWaitForIdle() {
6306 }6411 }
6307 if cancel != nil {6412 if cancel != nil {
6308 i.mu.Lock()6413 i.mu.Lock()
6309- i.resetCompactContinuationLocked()6414+ handoff, persistHandoff := i.resetCompactContinuationLocked()
6310 i.mu.Unlock()6415 i.mu.Unlock()
6416+ if persistHandoff {
6417+ i.persistCompactHandoff(handoff)
6418+ }
6311 cancel()6419 cancel()
6312 }6420 }
6313 // ConfirmToolCall waits on its response channel rather than the turn6421 // ConfirmToolCall waits on its response channel rather than the turn
@@ -6371,14 +6479,19 @@ func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) {
6371 // Keep the mutex held while enqueueing so the turn-completion6479 // Keep the mutex held while enqueueing so the turn-completion
6372 // goroutine cannot publish idle and inspect an empty queue between6480 // goroutine cannot publish idle and inspect an empty queue between
6373 // this check and the append.6481 // this check and the append.
6482+ var handoff json.RawMessage
6483+ var persistHandoff bool
6374 if i.compacting {6484 if i.compacting {
6375- i.resetCompactContinuationLocked()6485+ handoff, persistHandoff = i.resetCompactContinuationLocked()
6376 i.queued = append(i.queued, prompt)6486 i.queued = append(i.queued, prompt)
6377 } else {6487 } else {
6378- i.resetCompactContinuationLocked()6488+ handoff, persistHandoff = i.resetCompactContinuationLocked()
6379 i.agent.QueueMessage(prompt)6489 i.agent.QueueMessage(prompt)
6380 }6490 }
6381 i.mu.Unlock()6491 i.mu.Unlock()
6492+ if persistHandoff {
6493+ i.persistCompactHandoff(handoff)
6494+ }
6382 i.invalidate()6495 i.invalidate()
6383 return6496 return
6384 }6497 }
@@ -6525,7 +6638,9 @@ func (i *Interactive) applySessionSelection(path string) {
6525 return6638 return
6526 }6639 }
6527 i.restoreLoadedSessionTitle()6640 i.restoreLoadedSessionTitle()
6641+ state := decodeCompactHandoff(i.currentCompactHandoff())
6528 i.mu.Lock()6642 i.mu.Lock()
6643+ i.compactContinuation = state
6529 i.sessionLoading = false6644 i.sessionLoading = false
6530 i.statusOK = "resumed session: " + path6645 i.statusOK = "resumed session: " + path
6531 i.statusErr = ""6646 i.statusErr = ""
@@ -6540,7 +6655,7 @@ func (i *Interactive) applySessionSelection(path string) {
6540 i.extNotes = nil6655 i.extNotes = nil
6541 i.view.InvalidateRenderCache()6656 i.view.InvalidateRenderCache()
6542 if i.agent != nil {6657 if i.agent != nil {
6543- i.view.Messages = i.agent.Messages()6658+ i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
6544 i.cumUsage = i.agent.Cost()6659 i.cumUsage = i.agent.Cost()
6545 if last := i.agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 {6660 if last := i.agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 {
6546 i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens6661 i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
@@ -6558,6 +6673,9 @@ func (i *Interactive) applySessionSelection(path string) {
6558 }6673 }
6559 i.mu.Unlock()6674 i.mu.Unlock()
6560 i.invalidate()6675 i.invalidate()
6676+ if state.reason != compactContinuationNone {
6677+ i.startRestoredCompactHandoff(i.runCtx)
6678+ }
6561 }()6679 }()
6562 }6680 }
6563 6681
@@ -6581,25 +6699,30 @@ func (i *Interactive) applyRescueModelSelection(prov, model string) {
6581 // the supplied builder. rescue=true tags the success message so the6699 // the supplied builder. rescue=true tags the success message so the
6582 // user can see that launch-time overrides were ignored.6700 // user can see that launch-time overrides were ignored.
6583 func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {6701 func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
6702+ var replaced bool
6703+ swap := func() {
6704+ replaced = i.swapModelUnserialized(prov, model, builder, rescue)
6705+ }
6584 if i.cfg.SessionTransition != nil {6706 if i.cfg.SessionTransition != nil {
6585- i.cfg.SessionTransition(func() {6707+ i.cfg.SessionTransition(swap)
6586- i.swapModelUnserialized(prov, model, builder, rescue)6708+ } else {
6587- })6709+ swap()
6588- return6710+ }
6711+ if replaced {
6712+ i.resetCompactHandoff()
6589 }6713 }
6590- i.swapModelUnserialized(prov, model, builder, rescue)
6591 }6714 }
6592 6715
6593-func (i *Interactive) swapModelUnserialized(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {6716+func (i *Interactive) swapModelUnserialized(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) bool {
6594 if model == "" {6717 if model == "" {
6595- return6718+ return false
6596 }6719 }
6597 m, err := provider.FindModel(prov, model)6720 m, err := provider.FindModel(prov, model)
6598 if err != nil {6721 if err != nil {
6599 i.mu.Lock()6722 i.mu.Lock()
6600 i.statusErr = err.Error()6723 i.statusErr = err.Error()
6601 i.mu.Unlock()6724 i.mu.Unlock()
6602- return6725+ return false
6603 }6726 }
6604 // Same provider AND not a rescue retry: just swap the model on6727 // Same provider AND not a rescue retry: just swap the model on
6605 // the existing agent. Mixed-API providers dispatch from model metadata,6728 // the existing agent. Mixed-API providers dispatch from model metadata,
@@ -6615,13 +6738,13 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str
6615 if i.cfg.PersistModel != nil {6738 if i.cfg.PersistModel != nil {
6616 i.cfg.PersistModel(i.cfg.Provider, m.ID)6739 i.cfg.PersistModel(i.cfg.Provider, m.ID)
6617 }6740 }
6618- return6741+ return false
6619 }6742 }
6620 if builder == nil {6743 if builder == nil {
6621 i.mu.Lock()6744 i.mu.Lock()
6622 i.statusErr = "cannot switch provider: no builder configured"6745 i.statusErr = "cannot switch provider: no builder configured"
6623 i.mu.Unlock()6746 i.mu.Unlock()
6624- return6747+ return false
6625 }6748 }
6626 // Snapshot the current transcript and cumulative usage BEFORE we6749 // Snapshot the current transcript and cumulative usage BEFORE we
6627 // build the replacement agent so we can hand them off. Without6750 // build the replacement agent so we can hand them off. Without
@@ -6639,7 +6762,7 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str
6639 i.mu.Lock()6762 i.mu.Lock()
6640 i.statusErr = err.Error()6763 i.statusErr = err.Error()
6641 i.mu.Unlock()6764 i.mu.Unlock()
6642- return6765+ return false
6643 }6766 }
6644 6767
6645 // Replay the transcript and seed the cost on the freshly-built6768 // Replay the transcript and seed the cost on the freshly-built
@@ -6682,6 +6805,7 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str
6682 if i.cfg.PersistModel != nil {6805 if i.cfg.PersistModel != nil {
6683 i.cfg.PersistModel(p, md)6806 i.cfg.PersistModel(p, md)
6684 }6807 }
6808+ return true
6685 }6809 }
6686 6810
6687 func (i *Interactive) handleAuthEvent(ev auth.Event) {6811 func (i *Interactive) handleAuthEvent(ev auth.Event) {
@@ -6697,6 +6821,7 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
6697 // same transition as settings/session changes. Otherwise a build that6821 // same transition as settings/session changes. Otherwise a build that
6698 // started under the old web-search policy could commit afterward.6822 // started under the old web-search policy could commit afterward.
6699 var buildErr error6823 var buildErr error
6824+ var clearHandoff bool
6700 buildAndCommitLogin := func() {6825 buildAndCommitLogin := func() {
6701 ag, prov, model, err := i.cfg.BuildAgent()6826 ag, prov, model, err := i.cfg.BuildAgent()
6702 if err != nil {6827 if err != nil {
@@ -6707,6 +6832,7 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
6707 i.mu.Lock()6832 i.mu.Lock()
6708 oldAgent := i.agent6833 oldAgent := i.agent
6709 i.prepareReplacementAgentLocked(ag)6834 i.prepareReplacementAgentLocked(ag)
6835+ _, clearHandoff = i.resetCompactContinuationLocked()
6710 i.agent = ag6836 i.agent = ag
6711 i.cfg.Provider = prov6837 i.cfg.Provider = prov
6712 i.cfg.Model = model6838 i.cfg.Model = model
@@ -6734,6 +6860,9 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
6734 i.dialog.ShowResult(false, buildErr.Error())6860 i.dialog.ShowResult(false, buildErr.Error())
6735 return6861 return
6736 }6862 }
6863+ if clearHandoff {
6864+ i.persistCompactHandoff(nil)
6865+ }
6737 i.dialog.ShowResult(true, "")6866 i.dialog.ShowResult(true, "")
6738 }6867 }
6739 }6868 }
@@ -6748,14 +6877,168 @@ func (i *Interactive) clearPendingCompactTurnLocked() {
6748 i.pendingPostCompactNote = ""6877 i.pendingPostCompactNote = ""
6749 }6878 }
6750 6879
6751-// resetCompactContinuationLocked clears the private handoff state. The6880+// compactHandoffLocked returns a self-contained persistence snapshot. The
6752 // caller must hold i.mu.6881 // caller must hold i.mu.
6753-func (i *Interactive) resetCompactContinuationLocked() {6882+func (i *Interactive) compactHandoffLocked() json.RawMessage {
6754- i.compactContinuation = compactContinuationState{}6883+ return encodeCompactHandoff(i.compactContinuation)
6884+}
6885+
6886+// setCompactContinuationLocked updates the private handoff state and returns
6887+// its persistence snapshot plus whether persistence is needed. The caller must
6888+// hold i.mu.
6889+func (i *Interactive) setCompactContinuationLocked(state compactContinuationState) (json.RawMessage, bool) {
6890+ if i.compactContinuation == state {
6891+ return i.compactHandoffLocked(), false
6892+ }
6893+ i.compactContinuation = state
6894+ return i.compactHandoffLocked(), true
6895+}
6896+
6897+// resetCompactContinuationLocked clears the private handoff state and returns
6898+// its persistence snapshot plus whether persistence is needed. The caller must
6899+// hold i.mu.
6900+func (i *Interactive) resetCompactContinuationLocked() (json.RawMessage, bool) {
6901+ return i.setCompactContinuationLocked(compactContinuationState{})
6902+}
6903+
6904+func (i *Interactive) persistCompactHandoff(state json.RawMessage) {
6905+ if persist := i.cfg.PersistCompactHandoff; persist != nil {
6906+ if err := persist(state); err != nil {
6907+ i.ReportError(fmt.Errorf("persist compact handoff: %w", err))
6908+ }
6909+ }
6910+}
6911+
6912+func (i *Interactive) resetCompactHandoff() {
6913+ i.mu.Lock()
6914+ state, persist := i.resetCompactContinuationLocked()
6915+ i.mu.Unlock()
6916+ if persist {
6917+ i.persistCompactHandoff(state)
6918+ }
6919+}
6920+
6921+func (i *Interactive) currentCompactHandoff() json.RawMessage {
6922+ if current := i.cfg.CurrentCompactHandoff; current != nil {
6923+ return current()
6924+ }
6925+ return nil
6926+}
6927+
6928+func (i *Interactive) restoreCompactHandoff(state compactContinuationState) {
6929+ i.mu.Lock()
6930+ i.compactContinuation = state
6931+ i.mu.Unlock()
6932+}
6933+
6934+func (i *Interactive) restoreCurrentCompactHandoff() compactContinuationState {
6935+ state := decodeCompactHandoff(i.currentCompactHandoff())
6936+ i.restoreCompactHandoff(state)
6937+ return state
6755 }6938 }
6756 6939
6757 const autoCompactContinuationPrompt = `Context was compacted while work was in progress. Continue the user's most recent active request now; do not wait for them to type "continue". Inspect the context summary and kept recent messages, treating active constraints and preferences there as still in force, including delegation/subagent instructions. If a newer request supersedes earlier plans in the summary, follow the newer request. A progress report, plan update, or statement of future work is not completion: if required work remains, take the next concrete action now. Only finish when the request is actually complete or when a specific user decision is required. If nothing remains, give a brief truthful completion; do not invent work or force a tool call.`6940 const autoCompactContinuationPrompt = `Context was compacted while work was in progress. Continue the user's most recent active request now; do not wait for them to type "continue". Inspect the context summary and kept recent messages, treating active constraints and preferences there as still in force, including delegation/subagent instructions. If a newer request supersedes earlier plans in the summary, follow the newer request. A progress report, plan update, or statement of future work is not completion: if required work remains, take the next concrete action now. Only finish when the request is actually complete or when a specific user decision is required. If nothing remains, give a brief truthful completion; do not invent work or force a tool call.`
6758 6941
6942+type compactHandoffResume uint8
6943+
6944+const (
6945+ compactHandoffDiscard compactHandoffResume = iota
6946+ compactHandoffContinueExisting
6947+ compactHandoffAppendPrompt
6948+)
6949+
6950+// classifyCompactHandoffResume validates that the persisted checkpoint still
6951+// describes the effective transcript. A normal assistant reply or a newer
6952+// explicit user message supersedes the checkpoint; tool activity remains an
6953+// unfinished existing turn. No hidden prompt means the process stopped after
6954+// recording the handoff but before appending its context message.
6955+func classifyCompactHandoffResume(messages []provider.Message) compactHandoffResume {
6956+ lastHandoff := -1
6957+ for idx := len(messages) - 1; idx >= 0; idx-- {
6958+ message := messages[idx]
6959+ if message.Role == provider.RoleUser && message.Meta[autoCompactContinueMetaKey] == "true" {
6960+ lastHandoff = idx
6961+ break
6962+ }
6963+ }
6964+ if lastHandoff < 0 {
6965+ return compactHandoffAppendPrompt
6966+ }
6967+ for _, message := range messages[lastHandoff+1:] {
6968+ switch message.Role {
6969+ case provider.RoleTool:
6970+ continue
6971+ case provider.RoleAssistant:
6972+ hasToolCall := false
6973+ for _, content := range message.Content {
6974+ if _, ok := content.(provider.ToolCallBlock); ok {
6975+ hasToolCall = true
6976+ break
6977+ }
6978+ }
6979+ if !hasToolCall {
6980+ return compactHandoffDiscard
6981+ }
6982+ default:
6983+ return compactHandoffDiscard
6984+ }
6985+ }
6986+ return compactHandoffContinueExisting
6987+}
6988+
6989+// startRestoredCompactHandoff resumes a handoff checkpoint restored from the
6990+// active session. When its hidden context message was already persisted, the
6991+// agent continues that existing turn rather than appending it again.
6992+func (i *Interactive) startRestoredCompactHandoff(parent context.Context) {
6993+ if parent == nil {
6994+ parent = i.runCtx
6995+ }
6996+ if parent == nil {
6997+ parent = context.Background()
6998+ }
6999+ i.mu.Lock()
7000+ state := i.compactContinuation
7001+ ag := i.agent
7002+ busy := i.busy || i.compacting || i.autoCompacting || i.shellRunning
7003+ if state.reason == compactContinuationNone || ag == nil || busy {
7004+ i.mu.Unlock()
7005+ return
7006+ }
7007+
7008+ var next string
7009+ continueQueued := false
7010+ var resume compactHandoffResume
7011+ var handoff json.RawMessage
7012+ var persistHandoff bool
7013+ switch {
7014+ case state.reason != compactContinuationForcedLength && len(i.queued) > 0:
7015+ next, i.queued = i.queued[0], i.queued[1:]
7016+ handoff, persistHandoff = i.resetCompactContinuationLocked()
7017+ case state.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0:
7018+ continueQueued = true
7019+ handoff, persistHandoff = i.resetCompactContinuationLocked()
7020+ default:
7021+ resume = classifyCompactHandoffResume(ag.Messages())
7022+ if resume == compactHandoffDiscard {
7023+ handoff, persistHandoff = i.resetCompactContinuationLocked()
7024+ }
7025+ }
7026+ i.mu.Unlock()
7027+ if persistHandoff {
7028+ i.persistCompactHandoff(handoff)
7029+ }
7030+ switch {
7031+ case next != "":
7032+ i.startTurn(parent, next)
7033+ case continueQueued:
7034+ i.startTurnRequest(parent, "", nil, true, false)
7035+ case resume == compactHandoffContinueExisting:
7036+ i.startTurnRequest(parent, "", nil, true, false)
7037+ case resume == compactHandoffAppendPrompt:
7038+ i.startAutoCompactContinuation(parent)
7039+ }
7040+}
7041+
6759 // startAutoCompactContinuation adds an internal user turn that tells the7042 // startAutoCompactContinuation adds an internal user turn that tells the
6760 // model to resume the task after threshold compaction. Provider requests7043 // model to resume the task after threshold compaction. Provider requests
6761 // cannot continue from an assistant tail on every supported API, so the7044 // cannot continue from an assistant tail on every supported API, so the
@@ -6779,14 +7062,20 @@ func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
6779 if i.compactContinuation.reason != compactContinuationForcedLength && len(i.queued) > 0 {7062 if i.compactContinuation.reason != compactContinuationForcedLength && len(i.queued) > 0 {
6780 next := i.queued[0]7063 next := i.queued[0]
6781 i.queued = i.queued[1:]7064 i.queued = i.queued[1:]
6782- i.resetCompactContinuationLocked()7065+ handoff, persistHandoff := i.resetCompactContinuationLocked()
6783 i.mu.Unlock()7066 i.mu.Unlock()
7067+ if persistHandoff {
7068+ i.persistCompactHandoff(handoff)
7069+ }
6784 i.startTurn(parent, next)7070 i.startTurn(parent, next)
6785 return7071 return
6786 }7072 }
6787 if i.compactContinuation.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0 {7073 if i.compactContinuation.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0 {
6788- i.resetCompactContinuationLocked()7074+ handoff, persistHandoff := i.resetCompactContinuationLocked()
6789 i.mu.Unlock()7075 i.mu.Unlock()
7076+ if persistHandoff {
7077+ i.persistCompactHandoff(handoff)
7078+ }
6790 i.startTurnRequest(parent, "", nil, true, false)7079 i.startTurnRequest(parent, "", nil, true, false)
6791 return7080 return
6792 }7081 }
@@ -6827,8 +7116,10 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
6827 i.activity.kind = activityCompactingHistory7116 i.activity.kind = activityCompactingHistory
6828 }7117 }
6829 i.cancelTurn = cancel7118 i.cancelTurn = cancel
7119+ var initialHandoff json.RawMessage
7120+ persistInitialHandoff := false
6830 if request.origin == compactOriginManual || request.origin == compactOriginRecovery {7121 if request.origin == compactOriginManual || request.origin == compactOriginRecovery {
6831- i.resetCompactContinuationLocked()7122+ initialHandoff, persistInitialHandoff = i.resetCompactContinuationLocked()
6832 }7123 }
6833 i.statusErr = ""7124 i.statusErr = ""
6834 i.statusOK = ""7125 i.statusOK = ""
@@ -6838,6 +7129,9 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
6838 i.scrollOffset = 07129 i.scrollOffset = 0
6839 i.helpBlock = nil7130 i.helpBlock = nil
6840 i.mu.Unlock()7131 i.mu.Unlock()
7132+ if persistInitialHandoff {
7133+ i.persistCompactHandoff(initialHandoff)
7134+ }
6841 i.invalidate()7135 i.invalidate()
6842 7136
6843 go func() {7137 go func() {
@@ -6880,6 +7174,8 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
6880 var hasNext bool7174 var hasNext bool
6881 var continueExisting bool7175 var continueExisting bool
6882 var continueAutomatically bool7176 var continueAutomatically bool
7177+ var handoff json.RawMessage
7178+ var persistHandoff bool
6883 7179
6884 switch {7180 switch {
6885 case err != nil && ctx.Err() != nil:7181 case err != nil && ctx.Err() != nil:
@@ -6891,7 +7187,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
6891 }7187 }
6892 i.queued = nil // drop queue on cancel7188 i.queued = nil // drop queue on cancel
6893 i.clearPendingCompactTurnLocked()7189 i.clearPendingCompactTurnLocked()
6894- i.resetCompactContinuationLocked()7190+ handoff, persistHandoff = i.resetCompactContinuationLocked()
6895 if i.agent != nil {7191 if i.agent != nil {
6896 i.agent.DrainQueuedMessages()7192 i.agent.DrainQueuedMessages()
6897 }7193 }
@@ -6900,7 +7196,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
6900 i.statusOK = ""7196 i.statusOK = ""
6901 i.queued = nil // drop queue on error7197 i.queued = nil // drop queue on error
6902 i.clearPendingCompactTurnLocked()7198 i.clearPendingCompactTurnLocked()
6903- i.resetCompactContinuationLocked()7199+ handoff, persistHandoff = i.resetCompactContinuationLocked()
6904 if i.agent != nil {7200 if i.agent != nil {
6905 i.agent.DrainQueuedMessages()7201 i.agent.DrainQueuedMessages()
6906 }7202 }
@@ -6941,12 +7237,12 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
6941 case continuationReason == compactContinuationForcedLength:7237 case continuationReason == compactContinuationForcedLength:
6942 // Forced truncated-output continuation keeps its existing7238 // Forced truncated-output continuation keeps its existing
6943 // priority over an explicitly queued prompt.7239 // priority over an explicitly queued prompt.
6944- i.compactContinuation = compactContinuationState{reason: continuationReason}7240+ handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
6945 continueAutomatically = true7241 continueAutomatically = true
6946 case len(i.queued) > 0:7242 case len(i.queued) > 0:
6947 next, i.queued = i.queued[0], i.queued[1:]7243 next, i.queued = i.queued[0], i.queued[1:]
6948 hasNext = true7244 hasNext = true
6949- i.resetCompactContinuationLocked()7245+ handoff, persistHandoff = i.resetCompactContinuationLocked()
6950 case continuationReason == compactContinuationStructuralTail || continuationReason == compactContinuationStatusRescue:7246 case continuationReason == compactContinuationStructuralTail || continuationReason == compactContinuationStatusRescue:
6951 if continuationReason == compactContinuationStatusRescue {7247 if continuationReason == compactContinuationStatusRescue {
6952 attempts := 17248 attempts := 1
@@ -6954,19 +7250,19 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
6954 attempts = i.compactContinuation.rescueAttempts + 17250 attempts = i.compactContinuation.rescueAttempts + 1
6955 }7251 }
6956 if attempts > maxStatusRescueContinuations {7252 if attempts > maxStatusRescueContinuations {
6957- i.resetCompactContinuationLocked()7253+ handoff, persistHandoff = i.resetCompactContinuationLocked()
6958 break7254 break
6959 }7255 }
6960- i.compactContinuation = compactContinuationState{7256+ handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{
6961 reason: continuationReason,7257 reason: continuationReason,
6962 rescueAttempts: attempts,7258 rescueAttempts: attempts,
6963- }7259+ })
6964 } else {7260 } else {
6965- i.compactContinuation = compactContinuationState{reason: continuationReason}7261+ handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
6966 }7262 }
6967 continueAutomatically = true7263 continueAutomatically = true
6968 default:7264 default:
6969- i.resetCompactContinuationLocked()7265+ handoff, persistHandoff = i.resetCompactContinuationLocked()
6970 }7266 }
6971 }7267 }
6972 // Keep the host busy until the hand-off decision is committed under7268 // Keep the host busy until the hand-off decision is committed under
@@ -6977,6 +7273,9 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
6977 i.compacting = false7273 i.compacting = false
6978 i.autoCompacting = false7274 i.autoCompacting = false
6979 i.mu.Unlock()7275 i.mu.Unlock()
7276+ if persistHandoff {
7277+ i.persistCompactHandoff(handoff)
7278+ }
6980 runPendingIdleWork(pendingIdleWork)7279 runPendingIdleWork(pendingIdleWork)
6981 i.invalidate()7280 i.invalidate()
6982 7281
@@ -7156,8 +7455,10 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
7156 // The condense flow re-fires the user's queued prompt for us, so7455 // The condense flow re-fires the user's queued prompt for us, so
7157 // we just hand it off and exit.7456 // we just hand it off and exit.
7158 i.mu.Lock()7457 i.mu.Lock()
7458+ var resetHandoff json.RawMessage
7459+ var persistResetHandoff bool
7159 if !continueExisting {7460 if !continueExisting {
7160- i.resetCompactContinuationLocked()7461+ resetHandoff, persistResetHandoff = i.resetCompactContinuationLocked()
7161 }7462 }
7162 needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()7463 needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()
7163 if needsPreCompact {7464 if needsPreCompact {
@@ -7178,11 +7479,17 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
7178 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))7479 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))
7179 i.pendingPostCompactNote = "context auto-compacted; sending your last message"7480 i.pendingPostCompactNote = "context auto-compacted; sending your last message"
7180 i.mu.Unlock()7481 i.mu.Unlock()
7482+ if persistResetHandoff {
7483+ i.persistCompactHandoff(resetHandoff)
7484+ }
7181 i.invalidate()7485 i.invalidate()
7182 i.runCompact(parent, compactContinuationRequest{origin: compactOriginPreTurnThreshold})7486 i.runCompact(parent, compactContinuationRequest{origin: compactOriginPreTurnThreshold})
7183 return7487 return
7184 }7488 }
7185 i.mu.Unlock()7489 i.mu.Unlock()
7490+ if persistResetHandoff {
7491+ i.persistCompactHandoff(resetHandoff)
7492+ }
7186 7493
7187 ctx, cancel := context.WithCancel(parent)7494 ctx, cancel := context.WithCancel(parent)
7188 i.mu.Lock()7495 i.mu.Lock()
@@ -7349,18 +7656,23 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
7349 continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil7656 continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil
7350 shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()7657 shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
7351 continueStatusRescue := false7658 continueStatusRescue := false
7659+ var handoff json.RawMessage
7660+ var persistHandoff bool
7352 if statusRescueActive && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil {7661 if statusRescueActive && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil {
7353 followUpMessages := i.agent.Messages()7662 followUpMessages := i.agent.Messages()
7354 reason := classifyCompactionContinuation(compactOriginManual, true, lastStop, lastTurnErr, followUpMessages)7663 reason := classifyCompactionContinuation(compactOriginManual, true, lastStop, lastTurnErr, followUpMessages)
7355 if reason == compactContinuationStatusRescue && i.compactContinuation.rescueAttempts < maxStatusRescueContinuations {7664 if reason == compactContinuationStatusRescue && i.compactContinuation.rescueAttempts < maxStatusRescueContinuations {
7356- i.compactContinuation.rescueAttempts++7665+ handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{
7666+ reason: compactContinuationStatusRescue,
7667+ rescueAttempts: i.compactContinuation.rescueAttempts + 1,
7668+ })
7357 continueStatusRescue = true7669 continueStatusRescue = true
7358 } else {7670 } else {
7359- i.resetCompactContinuationLocked()7671+ handoff, persistHandoff = i.resetCompactContinuationLocked()
7360 }7672 }
7361 }7673 }
7362 if !continueStatusRescue && (ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) {7674 if !continueStatusRescue && (ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) {
7363- i.resetCompactContinuationLocked()7675+ handoff, persistHandoff = i.resetCompactContinuationLocked()
7364 }7676 }
7365 // The agent run can finish before the paced final text reaches the7677 // The agent run can finish before the paced final text reaches the
7366 // transcript. A compaction replaces that transcript, so it must never7678 // transcript. A compaction replaces that transcript, so it must never
@@ -7372,6 +7684,9 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
7372 alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact)7684 alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact)
7373 i.busy = hasNext || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact7685 i.busy = hasNext || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact
7374 i.mu.Unlock()7686 i.mu.Unlock()
7687+ if persistHandoff {
7688+ i.persistCompactHandoff(handoff)
7689+ }
7375 if alertReason != "" {7690 if alertReason != "" {
7376 i.scheduleMainAlert(alertReason)7691 i.scheduleMainAlert(alertReason)
7377 }7692 }
@@ -7866,7 +8181,6 @@ func (i *Interactive) applyAgentPromptConfig(ag *core.Agent, system string, tool
7866 // prepareReplacementAgentLocked applies session-wide capability policy before8181 // prepareReplacementAgentLocked applies session-wide capability policy before
7867 // ag becomes visible to prompt submission. The caller holds agentMu and i.mu.8182 // ag becomes visible to prompt submission. The caller holds agentMu and i.mu.
7868 func (i *Interactive) prepareReplacementAgentLocked(ag *core.Agent) {8183 func (i *Interactive) prepareReplacementAgentLocked(ag *core.Agent) {
7869- i.resetCompactContinuationLocked()
7870 if ag == nil {8184 if ag == nil {
7871 i.setWebSearchAvailable(false)8185 i.setWebSearchAvailable(false)
7872 return8186 return
@@ -8778,11 +9092,27 @@ func (i *Interactive) doSessionImport(src string) {
8778 return9092 return
8779 }9093 }
8780 i.restoreLoadedSessionTitle()9094 i.restoreLoadedSessionTitle()
9095+ state := i.restoreCurrentCompactHandoff()
8781 i.mu.Lock()9096 i.mu.Lock()
8782 i.statusOK = "imported and switched to session " + friendlyPath(newPath)9097 i.statusOK = "imported and switched to session " + friendlyPath(newPath)
8783 i.statusErr = ""9098 i.statusErr = ""
9099+ if i.agent != nil {
9100+ i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
9101+ i.cumUsage = i.agent.Cost()
9102+ last := i.agent.LastTurnUsage()
9103+ i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
9104+ if len(i.view.Messages) > initialResumeTailLimit {
9105+ i.view.TailLimit = initialResumeTailLimit
9106+ } else {
9107+ i.view.TailLimit = 0
9108+ }
9109+ i.view.InvalidateRenderCache()
9110+ }
8784 i.mu.Unlock()9111 i.mu.Unlock()
8785 i.invalidate()9112 i.invalidate()
9113+ if state.reason != compactContinuationNone {
9114+ i.startRestoredCompactHandoff(i.runCtx)
9115+ }
8786 }9116 }
8787 9117
8788 // defaultExportDir returns ~/Downloads when it exists, or ~ as a9118 // defaultExportDir returns ~/Downloads when it exists, or ~ as a
@@ -8997,6 +9327,7 @@ func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo in
8997 i.setSessionTreeError("tree: checkout failed: " + err.Error())9327 i.setSessionTreeError("tree: checkout failed: " + err.Error())
8998 return9328 return
8999 }9329 }
9330+ state := i.restoreCurrentCompactHandoff()
9000 9331
9001 // LoadSession is intentionally the old func(string) error callback: the9332 // LoadSession is intentionally the old func(string) error callback: the
9002 // CLI and embedders already own agent/session swapping. Refresh the view9333 // CLI and embedders already own agent/session swapping. Refresh the view
@@ -9032,6 +9363,9 @@ func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo in
9032 // Submit, startTurn, or queue a message here; the restored user draft is9363 // Submit, startTurn, or queue a message here; the restored user draft is
9033 // submitted only by a later explicit Enter.9364 // submitted only by a later explicit Enter.
9034 i.invalidate()9365 i.invalidate()
9366+ if state.reason != compactContinuationNone {
9367+ i.startRestoredCompactHandoff(i.runCtx)
9368+ }
9035 }9369 }
9036 9370
9037 // applyForkSelection branches the current session at msgIdx+1 (so9371 // applyForkSelection branches the current session at msgIdx+1 (so
@@ -9079,12 +9413,16 @@ func (i *Interactive) applyForkSelection(msgIdx int) {
9079 i.invalidate()9413 i.invalidate()
9080 return9414 return
9081 }9415 }
9416+ state := i.restoreCurrentCompactHandoff()
9082 i.resetSessionTitleForFreshBranch()9417 i.resetSessionTitleForFreshBranch()
9083 i.mu.Lock()9418 i.mu.Lock()
9084 i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)9419 i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)
9085 i.statusErr = ""9420 i.statusErr = ""
9086 i.mu.Unlock()9421 i.mu.Unlock()
9087 i.invalidate()9422 i.invalidate()
9423+ if state.reason != compactContinuationNone {
9424+ i.startRestoredCompactHandoff(i.runCtx)
9425+ }
9088 }9426 }
9089 9427
9090 // formatInt is a tiny strconv.Itoa shim; keeps the handler above9428 // formatInt is a tiny strconv.Itoa shim; keeps the handler above
M packages/core/core_test.go +40 -0
@@ -256,6 +256,46 @@ func TestSessionRetainsExtensionStateWithoutMessages(t *testing.T) {
256256 }
257257 }
258258
259+func TestSessionCompactHandoffRoundTripAndClear(t *testing.T) {
260+ sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
261+ if err != nil {
262+ t.Fatal(err)
263+ }
264+ state := json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`)
265+ if err := sess.UpdateCompactHandoff(state); err != nil {
266+ t.Fatal(err)
267+ }
268+ if got := string(sess.Meta.CompactHandoff); got != string(state) {
269+ t.Fatalf("live compact handoff = %q, want %q", got, state)
270+ }
271+ if err := sess.Close(); err != nil {
272+ t.Fatal(err)
273+ }
274+
275+ reopened, _, err := OpenSession(sess.Path)
276+ if err != nil {
277+ t.Fatal(err)
278+ }
279+ if got := string(reopened.Meta.CompactHandoff); got != string(state) {
280+ t.Fatalf("reopened compact handoff = %q, want %q", got, state)
281+ }
282+ if err := reopened.UpdateCompactHandoff(nil); err != nil {
283+ t.Fatal(err)
284+ }
285+ if err := reopened.Close(); err != nil {
286+ t.Fatal(err)
287+ }
288+
289+ reopened, _, err = OpenSession(sess.Path)
290+ if err != nil {
291+ t.Fatal(err)
292+ }
293+ defer reopened.Close()
294+ if len(reopened.Meta.CompactHandoff) != 0 {
295+ t.Fatalf("cleared compact handoff = %q, want empty", reopened.Meta.CompactHandoff)
296+ }
297+}
298+
259299 func TestCostAdd(t *testing.T) {
260300 var c CostTracker
261301 c.Add(provider.Usage{InputTokens: 100, OutputTokens: 50, ReasoningTokens: 20, ReasoningTokensKnown: true, CostUSD: 0.01})
@@ -256,6 +256,46 @@ func TestSessionRetainsExtensionStateWithoutMessages(t *testing.T) {
256 }256 }
257 }257 }
258 258
259+func TestSessionCompactHandoffRoundTripAndClear(t *testing.T) {
260+ sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
261+ if err != nil {
262+ t.Fatal(err)
263+ }
264+ state := json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`)
265+ if err := sess.UpdateCompactHandoff(state); err != nil {
266+ t.Fatal(err)
267+ }
268+ if got := string(sess.Meta.CompactHandoff); got != string(state) {
269+ t.Fatalf("live compact handoff = %q, want %q", got, state)
270+ }
271+ if err := sess.Close(); err != nil {
272+ t.Fatal(err)
273+ }
274+
275+ reopened, _, err := OpenSession(sess.Path)
276+ if err != nil {
277+ t.Fatal(err)
278+ }
279+ if got := string(reopened.Meta.CompactHandoff); got != string(state) {
280+ t.Fatalf("reopened compact handoff = %q, want %q", got, state)
281+ }
282+ if err := reopened.UpdateCompactHandoff(nil); err != nil {
283+ t.Fatal(err)
284+ }
285+ if err := reopened.Close(); err != nil {
286+ t.Fatal(err)
287+ }
288+
289+ reopened, _, err = OpenSession(sess.Path)
290+ if err != nil {
291+ t.Fatal(err)
292+ }
293+ defer reopened.Close()
294+ if len(reopened.Meta.CompactHandoff) != 0 {
295+ t.Fatalf("cleared compact handoff = %q, want empty", reopened.Meta.CompactHandoff)
296+ }
297+}
298+
259 func TestCostAdd(t *testing.T) {299 func TestCostAdd(t *testing.T) {
260 var c CostTracker300 var c CostTracker
261 c.Add(provider.Usage{InputTokens: 100, OutputTokens: 50, ReasoningTokens: 20, ReasoningTokensKnown: true, CostUSD: 0.01})301 c.Add(provider.Usage{InputTokens: 100, OutputTokens: 50, ReasoningTokens: 20, ReasoningTokensKnown: true, CostUSD: 0.01})
M packages/core/session.go +28 -1
@@ -57,6 +57,11 @@ type SessionMeta struct {
5757 Version string `json:"version"`
5858 Title string `json:"title,omitempty"`
5959
60+ // CompactHandoff is opaque host-managed state for a live compaction
61+ // continuation. It is session metadata, never provider context.
62+ // Missing or invalid values are handled by the owning host as no handoff.
63+ CompactHandoff json.RawMessage `json:"compact_handoff,omitempty"`
64+
6065 // Parent is the ID of the session this one was forked from, or
6166 // empty for top-level sessions. The tree picker walks parents
6267 // upward and sibling files (same cwd dir, same parent ID)
@@ -1198,6 +1203,28 @@ func (s *Session) UpdateModel(providerName, model string) error {
11981203 return nil
11991204 }
12001205
1206+// UpdateCompactHandoff records opaque host-owned compaction-handoff state in
1207+// a new metadata row. A nil or JSON null value clears the state. The core
1208+// session layer deliberately does not interpret the payload.
1209+func (s *Session) UpdateCompactHandoff(state json.RawMessage) error {
1210+ if s == nil {
1211+ return nil
1212+ }
1213+ if len(state) > 0 && !json.Valid(state) {
1214+ return errors.New("compact handoff state is not valid JSON")
1215+ }
1216+ if strings.TrimSpace(string(state)) == "null" {
1217+ state = nil
1218+ }
1219+ previous := append(json.RawMessage(nil), s.Meta.CompactHandoff...)
1220+ s.Meta.CompactHandoff = append(json.RawMessage(nil), state...)
1221+ if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil {
1222+ s.Meta.CompactHandoff = previous
1223+ return fmt.Errorf("update compact handoff: %w", err)
1224+ }
1225+ return nil
1226+}
1227+
12011228 // UpdateTitle records a session title without adding anything to the
12021229 // conversation transcript. The title is kept in memory as well so the live
12031230 // session can restore it when the user switches sessions in the TUI.
@@ -1269,7 +1296,7 @@ func (s *Session) Close() error {
12691296 }
12701297 flushErr := s.buf.Flush()
12711298 closeErr := s.writer.Close()
1272- if s.freshFile && s.messagesAppended == 0 && len(s.ExtensionState) == 0 {
1299+ if s.freshFile && s.messagesAppended == 0 && len(s.ExtensionState) == 0 && len(s.Meta.CompactHandoff) == 0 {
12731300 // Best-effort cleanup. We deliberately don't propagate the
12741301 // remove error: if it fails (file already gone, perms changed)
12751302 // the worst case is one stale empty file in the listing.
@@ -57,6 +57,11 @@ type SessionMeta struct {
57 Version string `json:"version"`57 Version string `json:"version"`
58 Title string `json:"title,omitempty"`58 Title string `json:"title,omitempty"`
59 59
60+ // CompactHandoff is opaque host-managed state for a live compaction
61+ // continuation. It is session metadata, never provider context.
62+ // Missing or invalid values are handled by the owning host as no handoff.
63+ CompactHandoff json.RawMessage `json:"compact_handoff,omitempty"`
64+
60 // Parent is the ID of the session this one was forked from, or65 // Parent is the ID of the session this one was forked from, or
61 // empty for top-level sessions. The tree picker walks parents66 // empty for top-level sessions. The tree picker walks parents
62 // upward and sibling files (same cwd dir, same parent ID)67 // upward and sibling files (same cwd dir, same parent ID)
@@ -1198,6 +1203,28 @@ func (s *Session) UpdateModel(providerName, model string) error {
1198 return nil1203 return nil
1199 }1204 }
1200 1205
1206+// UpdateCompactHandoff records opaque host-owned compaction-handoff state in
1207+// a new metadata row. A nil or JSON null value clears the state. The core
1208+// session layer deliberately does not interpret the payload.
1209+func (s *Session) UpdateCompactHandoff(state json.RawMessage) error {
1210+ if s == nil {
1211+ return nil
1212+ }
1213+ if len(state) > 0 && !json.Valid(state) {
1214+ return errors.New("compact handoff state is not valid JSON")
1215+ }
1216+ if strings.TrimSpace(string(state)) == "null" {
1217+ state = nil
1218+ }
1219+ previous := append(json.RawMessage(nil), s.Meta.CompactHandoff...)
1220+ s.Meta.CompactHandoff = append(json.RawMessage(nil), state...)
1221+ if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil {
1222+ s.Meta.CompactHandoff = previous
1223+ return fmt.Errorf("update compact handoff: %w", err)
1224+ }
1225+ return nil
1226+}
1227+
1201 // UpdateTitle records a session title without adding anything to the1228 // UpdateTitle records a session title without adding anything to the
1202 // conversation transcript. The title is kept in memory as well so the live1229 // conversation transcript. The title is kept in memory as well so the live
1203 // session can restore it when the user switches sessions in the TUI.1230 // session can restore it when the user switches sessions in the TUI.
@@ -1269,7 +1296,7 @@ func (s *Session) Close() error {
1269 }1296 }
1270 flushErr := s.buf.Flush()1297 flushErr := s.buf.Flush()
1271 closeErr := s.writer.Close()1298 closeErr := s.writer.Close()
1272- if s.freshFile && s.messagesAppended == 0 && len(s.ExtensionState) == 0 {1299+ if s.freshFile && s.messagesAppended == 0 && len(s.ExtensionState) == 0 && len(s.Meta.CompactHandoff) == 0 {
1273 // Best-effort cleanup. We deliberately don't propagate the1300 // Best-effort cleanup. We deliberately don't propagate the
1274 // remove error: if it fails (file already gone, perms changed)1301 // remove error: if it fails (file already gone, perms changed)
1275 // the worst case is one stale empty file in the listing.1302 // the worst case is one stale empty file in the listing.
M packages/core/session_portable.go +20 -10
@@ -215,13 +215,14 @@ func ImportSession(srcPath, root, cwd, version string) (string, error) {
215215
216216 // Write a fresh meta row claiming ownership.
217217 importMeta := SessionMeta{
218- ID: newID,
219- CWD: cwd,
220- Model: snapshot.Meta.Model,
221- Provider: snapshot.Meta.Provider,
222- Started: time.Now().UTC(),
223- Version: version,
224- Title: snapshot.Title,
218+ ID: newID,
219+ CWD: cwd,
220+ Model: snapshot.Meta.Model,
221+ Provider: snapshot.Meta.Provider,
222+ Started: time.Now().UTC(),
223+ Version: version,
224+ Title: snapshot.Title,
225+ CompactHandoff: append(json.RawMessage(nil), snapshot.Meta.CompactHandoff...),
225226 }
226227 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &importMeta})
227228 if err != nil {
@@ -313,7 +314,8 @@ func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hi
313314 if err != nil {
314315 return "", err
315316 }
316- return writeBranchSession(root, cwd, version, snapshot.Meta, snapshot.Messages, snapshot.UsageCheckpoints, limit, hideFromSessions, extensionState)
317+ compactHandoff := compactHandoffAtBranch(snapshot.Meta, snapshot.Messages, limit)
318+ return writeBranchSession(root, cwd, version, snapshot.Meta, snapshot.Messages, snapshot.UsageCheckpoints, limit, hideFromSessions, extensionState, compactHandoff)
317319 }
318320
319321 // BranchSessionHiddenFromHistory creates a hidden tree branch from a
@@ -343,7 +345,7 @@ func BranchSessionHiddenFromHistory(parentPath, root, cwd, version string, segme
343345 if err != nil {
344346 return "", err
345347 }
346- return writeBranchSession(root, cwd, version, snapshot.Meta, segment.Messages, segment.UsageCheckpoints, limit, true, extensionState)
348+ return writeBranchSession(root, cwd, version, snapshot.Meta, segment.Messages, segment.UsageCheckpoints, limit, true, extensionState, nil)
347349 }
348350
349351 // readExtensionStateAtFork reconstructs the latest extension snapshot that was
@@ -423,7 +425,14 @@ func readExtensionStateAtFork(path string, limit int) (map[string]json.RawMessag
423425 return forkState, nil
424426 }
425427
426-func writeBranchSession(root, cwd, version string, parent SessionMeta, messages []provider.Message, checkpoints []SessionUsageCheckpoint, limit int, hideFromSessions bool, extensionState map[string]json.RawMessage) (string, error) {
428+func compactHandoffAtBranch(parent SessionMeta, messages []provider.Message, limit int) json.RawMessage {
429+ if len(parent.CompactHandoff) == 0 || limit != len(messages) {
430+ return nil
431+ }
432+ return append(json.RawMessage(nil), parent.CompactHandoff...)
433+}
434+
435+func writeBranchSession(root, cwd, version string, parent SessionMeta, messages []provider.Message, checkpoints []SessionUsageCheckpoint, limit int, hideFromSessions bool, extensionState map[string]json.RawMessage, compactHandoff json.RawMessage) (string, error) {
427436 dir := SessionsDir(root, cwd)
428437 if err := os.MkdirAll(dir, 0o755); err != nil {
429438 return "", err
@@ -464,6 +473,7 @@ func writeBranchSession(root, cwd, version string, parent SessionMeta, messages
464473 Parent: parent.ID,
465474 ForkPoint: limit,
466475 HideFromSessions: hideFromSessions,
476+ CompactHandoff: append(json.RawMessage(nil), compactHandoff...),
467477 }
468478 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta})
469479 if err != nil {
@@ -215,13 +215,14 @@ func ImportSession(srcPath, root, cwd, version string) (string, error) {
215 215
216 // Write a fresh meta row claiming ownership.216 // Write a fresh meta row claiming ownership.
217 importMeta := SessionMeta{217 importMeta := SessionMeta{
218- ID: newID,218+ ID: newID,
219- CWD: cwd,219+ CWD: cwd,
220- Model: snapshot.Meta.Model,220+ Model: snapshot.Meta.Model,
221- Provider: snapshot.Meta.Provider,221+ Provider: snapshot.Meta.Provider,
222- Started: time.Now().UTC(),222+ Started: time.Now().UTC(),
223- Version: version,223+ Version: version,
224- Title: snapshot.Title,224+ Title: snapshot.Title,
225+ CompactHandoff: append(json.RawMessage(nil), snapshot.Meta.CompactHandoff...),
225 }226 }
226 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &importMeta})227 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &importMeta})
227 if err != nil {228 if err != nil {
@@ -313,7 +314,8 @@ func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hi
313 if err != nil {314 if err != nil {
314 return "", err315 return "", err
315 }316 }
316- return writeBranchSession(root, cwd, version, snapshot.Meta, snapshot.Messages, snapshot.UsageCheckpoints, limit, hideFromSessions, extensionState)317+ compactHandoff := compactHandoffAtBranch(snapshot.Meta, snapshot.Messages, limit)
318+ return writeBranchSession(root, cwd, version, snapshot.Meta, snapshot.Messages, snapshot.UsageCheckpoints, limit, hideFromSessions, extensionState, compactHandoff)
317 }319 }
318 320
319 // BranchSessionHiddenFromHistory creates a hidden tree branch from a321 // BranchSessionHiddenFromHistory creates a hidden tree branch from a
@@ -343,7 +345,7 @@ func BranchSessionHiddenFromHistory(parentPath, root, cwd, version string, segme
343 if err != nil {345 if err != nil {
344 return "", err346 return "", err
345 }347 }
346- return writeBranchSession(root, cwd, version, snapshot.Meta, segment.Messages, segment.UsageCheckpoints, limit, true, extensionState)348+ return writeBranchSession(root, cwd, version, snapshot.Meta, segment.Messages, segment.UsageCheckpoints, limit, true, extensionState, nil)
347 }349 }
348 350
349 // readExtensionStateAtFork reconstructs the latest extension snapshot that was351 // readExtensionStateAtFork reconstructs the latest extension snapshot that was
@@ -423,7 +425,14 @@ func readExtensionStateAtFork(path string, limit int) (map[string]json.RawMessag
423 return forkState, nil425 return forkState, nil
424 }426 }
425 427
426-func writeBranchSession(root, cwd, version string, parent SessionMeta, messages []provider.Message, checkpoints []SessionUsageCheckpoint, limit int, hideFromSessions bool, extensionState map[string]json.RawMessage) (string, error) {428+func compactHandoffAtBranch(parent SessionMeta, messages []provider.Message, limit int) json.RawMessage {
429+ if len(parent.CompactHandoff) == 0 || limit != len(messages) {
430+ return nil
431+ }
432+ return append(json.RawMessage(nil), parent.CompactHandoff...)
433+}
434+
435+func writeBranchSession(root, cwd, version string, parent SessionMeta, messages []provider.Message, checkpoints []SessionUsageCheckpoint, limit int, hideFromSessions bool, extensionState map[string]json.RawMessage, compactHandoff json.RawMessage) (string, error) {
427 dir := SessionsDir(root, cwd)436 dir := SessionsDir(root, cwd)
428 if err := os.MkdirAll(dir, 0o755); err != nil {437 if err := os.MkdirAll(dir, 0o755); err != nil {
429 return "", err438 return "", err
@@ -464,6 +473,7 @@ func writeBranchSession(root, cwd, version string, parent SessionMeta, messages
464 Parent: parent.ID,473 Parent: parent.ID,
465 ForkPoint: limit,474 ForkPoint: limit,
466 HideFromSessions: hideFromSessions,475 HideFromSessions: hideFromSessions,
476+ CompactHandoff: append(json.RawMessage(nil), compactHandoff...),
467 }477 }
468 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta})478 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta})
469 if err != nil {479 if err != nil {
M packages/core/session_portable_test.go +95 -0
@@ -29,6 +29,10 @@ func TestSessionExportImportRoundTrip(t *testing.T) {
2929 Role: provider.RoleAssistant,
3030 Content: []provider.Content{provider.TextBlock{Text: "hi — reply from the assistant"}},
3131 })
32+ handoff := json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`)
33+ if err := sess.UpdateCompactHandoff(handoff); err != nil {
34+ t.Fatal(err)
35+ }
3236 _ = sess.Close()
3337
3438 // Export to a directory — helper should build a name inside it.
@@ -70,6 +74,9 @@ func TestSessionExportImportRoundTrip(t *testing.T) {
7074 if imported.Meta.Model != "claude-opus-4-7" {
7175 t.Errorf("model not preserved: %q", imported.Meta.Model)
7276 }
77+ if got := string(imported.Meta.CompactHandoff); got != string(handoff) {
78+ t.Errorf("compact handoff = %q, want %q", got, handoff)
79+ }
7380 if len(msgs) != 2 {
7481 t.Fatalf("want 2 messages, got %d", len(msgs))
7582 }
@@ -494,6 +501,94 @@ func TestBranchSessionUsesEffectiveTranscriptAfterCompaction(t *testing.T) {
494501 }
495502 }
496503
504+func TestBranchSessionCompactHandoffFollowsCopiedEffectiveTail(t *testing.T) {
505+ root := t.TempDir()
506+ cwd := "/project"
507+ parent, err := NewSession(root, cwd, "anthropic", "claude", "test")
508+ if err != nil {
509+ t.Fatal(err)
510+ }
511+ for _, text := range []string{"first", "second"} {
512+ if err := parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: text}}}); err != nil {
513+ t.Fatal(err)
514+ }
515+ }
516+ handoff := json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`)
517+ if err := parent.UpdateCompactHandoff(handoff); err != nil {
518+ t.Fatal(err)
519+ }
520+ if err := parent.Close(); err != nil {
521+ t.Fatal(err)
522+ }
523+
524+ fullPath, err := BranchSession(parent.Path, root, cwd, "test", 2)
525+ if err != nil {
526+ t.Fatal(err)
527+ }
528+ full, _, err := OpenSession(fullPath)
529+ if err != nil {
530+ t.Fatal(err)
531+ }
532+ t.Cleanup(func() { _ = full.Close() })
533+ if got := string(full.Meta.CompactHandoff); got != string(handoff) {
534+ t.Fatalf("full branch compact handoff = %q, want %q", got, handoff)
535+ }
536+
537+ prefixPath, err := BranchSession(parent.Path, root, cwd, "test", 1)
538+ if err != nil {
539+ t.Fatal(err)
540+ }
541+ prefix, _, err := OpenSession(prefixPath)
542+ if err != nil {
543+ t.Fatal(err)
544+ }
545+ t.Cleanup(func() { _ = prefix.Close() })
546+ if len(prefix.Meta.CompactHandoff) != 0 {
547+ t.Fatalf("prefix branch compact handoff = %q, want empty", prefix.Meta.CompactHandoff)
548+ }
549+}
550+
551+func TestBranchSessionHiddenFromHistoryClearsCompactHandoff(t *testing.T) {
552+ root := t.TempDir()
553+ cwd := "/project"
554+ parent, err := NewSession(root, cwd, "anthropic", "claude", "test")
555+ if err != nil {
556+ t.Fatal(err)
557+ }
558+ if err := parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "old"}}}); err != nil {
559+ t.Fatal(err)
560+ }
561+ if err := parent.AppendCompaction([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "summary"}}}}); err != nil {
562+ t.Fatal(err)
563+ }
564+ if err := parent.UpdateCompactHandoff(json.RawMessage(`{"version":1,"reason":"forced_length"}`)); err != nil {
565+ t.Fatal(err)
566+ }
567+ if err := parent.Close(); err != nil {
568+ t.Fatal(err)
569+ }
570+
571+ history, err := ReadSessionHistory(parent.Path)
572+ if err != nil {
573+ t.Fatal(err)
574+ }
575+ if len(history.Segments) < 2 {
576+ t.Fatalf("history segments = %d, want at least 2", len(history.Segments))
577+ }
578+ branchPath, err := BranchSessionHiddenFromHistory(parent.Path, root, cwd, "test", history.Segments[0], len(history.Segments[0].Messages))
579+ if err != nil {
580+ t.Fatal(err)
581+ }
582+ branch, _, err := OpenSession(branchPath)
583+ if err != nil {
584+ t.Fatal(err)
585+ }
586+ t.Cleanup(func() { _ = branch.Close() })
587+ if len(branch.Meta.CompactHandoff) != 0 {
588+ t.Fatalf("historical branch compact handoff = %q, want empty", branch.Meta.CompactHandoff)
589+ }
590+}
591+
497592 func TestBranchSessionCopiesExtensionStateAtFork(t *testing.T) {
498593 root := t.TempDir()
499594 cwd := "/project"
@@ -29,6 +29,10 @@ func TestSessionExportImportRoundTrip(t *testing.T) {
29 Role: provider.RoleAssistant,29 Role: provider.RoleAssistant,
30 Content: []provider.Content{provider.TextBlock{Text: "hi — reply from the assistant"}},30 Content: []provider.Content{provider.TextBlock{Text: "hi — reply from the assistant"}},
31 })31 })
32+ handoff := json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`)
33+ if err := sess.UpdateCompactHandoff(handoff); err != nil {
34+ t.Fatal(err)
35+ }
32 _ = sess.Close()36 _ = sess.Close()
33 37
34 // Export to a directory — helper should build a name inside it.38 // Export to a directory — helper should build a name inside it.
@@ -70,6 +74,9 @@ func TestSessionExportImportRoundTrip(t *testing.T) {
70 if imported.Meta.Model != "claude-opus-4-7" {74 if imported.Meta.Model != "claude-opus-4-7" {
71 t.Errorf("model not preserved: %q", imported.Meta.Model)75 t.Errorf("model not preserved: %q", imported.Meta.Model)
72 }76 }
77+ if got := string(imported.Meta.CompactHandoff); got != string(handoff) {
78+ t.Errorf("compact handoff = %q, want %q", got, handoff)
79+ }
73 if len(msgs) != 2 {80 if len(msgs) != 2 {
74 t.Fatalf("want 2 messages, got %d", len(msgs))81 t.Fatalf("want 2 messages, got %d", len(msgs))
75 }82 }
@@ -494,6 +501,94 @@ func TestBranchSessionUsesEffectiveTranscriptAfterCompaction(t *testing.T) {
494 }501 }
495 }502 }
496 503
504+func TestBranchSessionCompactHandoffFollowsCopiedEffectiveTail(t *testing.T) {
505+ root := t.TempDir()
506+ cwd := "/project"
507+ parent, err := NewSession(root, cwd, "anthropic", "claude", "test")
508+ if err != nil {
509+ t.Fatal(err)
510+ }
511+ for _, text := range []string{"first", "second"} {
512+ if err := parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: text}}}); err != nil {
513+ t.Fatal(err)
514+ }
515+ }
516+ handoff := json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`)
517+ if err := parent.UpdateCompactHandoff(handoff); err != nil {
518+ t.Fatal(err)
519+ }
520+ if err := parent.Close(); err != nil {
521+ t.Fatal(err)
522+ }
523+
524+ fullPath, err := BranchSession(parent.Path, root, cwd, "test", 2)
525+ if err != nil {
526+ t.Fatal(err)
527+ }
528+ full, _, err := OpenSession(fullPath)
529+ if err != nil {
530+ t.Fatal(err)
531+ }
532+ t.Cleanup(func() { _ = full.Close() })
533+ if got := string(full.Meta.CompactHandoff); got != string(handoff) {
534+ t.Fatalf("full branch compact handoff = %q, want %q", got, handoff)
535+ }
536+
537+ prefixPath, err := BranchSession(parent.Path, root, cwd, "test", 1)
538+ if err != nil {
539+ t.Fatal(err)
540+ }
541+ prefix, _, err := OpenSession(prefixPath)
542+ if err != nil {
543+ t.Fatal(err)
544+ }
545+ t.Cleanup(func() { _ = prefix.Close() })
546+ if len(prefix.Meta.CompactHandoff) != 0 {
547+ t.Fatalf("prefix branch compact handoff = %q, want empty", prefix.Meta.CompactHandoff)
548+ }
549+}
550+
551+func TestBranchSessionHiddenFromHistoryClearsCompactHandoff(t *testing.T) {
552+ root := t.TempDir()
553+ cwd := "/project"
554+ parent, err := NewSession(root, cwd, "anthropic", "claude", "test")
555+ if err != nil {
556+ t.Fatal(err)
557+ }
558+ if err := parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "old"}}}); err != nil {
559+ t.Fatal(err)
560+ }
561+ if err := parent.AppendCompaction([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "summary"}}}}); err != nil {
562+ t.Fatal(err)
563+ }
564+ if err := parent.UpdateCompactHandoff(json.RawMessage(`{"version":1,"reason":"forced_length"}`)); err != nil {
565+ t.Fatal(err)
566+ }
567+ if err := parent.Close(); err != nil {
568+ t.Fatal(err)
569+ }
570+
571+ history, err := ReadSessionHistory(parent.Path)
572+ if err != nil {
573+ t.Fatal(err)
574+ }
575+ if len(history.Segments) < 2 {
576+ t.Fatalf("history segments = %d, want at least 2", len(history.Segments))
577+ }
578+ branchPath, err := BranchSessionHiddenFromHistory(parent.Path, root, cwd, "test", history.Segments[0], len(history.Segments[0].Messages))
579+ if err != nil {
580+ t.Fatal(err)
581+ }
582+ branch, _, err := OpenSession(branchPath)
583+ if err != nil {
584+ t.Fatal(err)
585+ }
586+ t.Cleanup(func() { _ = branch.Close() })
587+ if len(branch.Meta.CompactHandoff) != 0 {
588+ t.Fatalf("historical branch compact handoff = %q, want empty", branch.Meta.CompactHandoff)
589+ }
590+}
591+
497 func TestBranchSessionCopiesExtensionStateAtFork(t *testing.T) {592 func TestBranchSessionCopiesExtensionStateAtFork(t *testing.T) {
498 root := t.TempDir()593 root := t.TempDir()
499 cwd := "/project"594 cwd := "/project"

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close