detached 3110ba7

Merge pull request #61 from bnema/feat/p1-persist-compact-handoff

feat(session): resume compact handoffs
commit
3110ba78643c603ec1413818810536d43cac5357
author
bnema · · 2026-08-07 08:07 UTC
committer
GitHub · · 2026-08-07 08:07 UTC
merge of
01ec125 + 8293ec4 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.7.0
12 files · +1122 -108
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. A new branch keeps that handoff only when it copies the complete effective transcript; `/fork` or `/session tree` selection at an earlier turn, and every branch from pre-compaction history, drops it and starts no hidden continuation. 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. A new branch keeps that handoff only when it copies the complete effective transcript; `/fork` or `/session tree` selection at an earlier turn, and every branch from pre-compaction history, drops it and starts no hidden continuation. 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 +97 -0
@@ -1,8 +1,10 @@
11 package modes
22
33 import (
4+ "encoding/json"
45 "errors"
56 "testing"
7+ "time"
68
79 "github.com/bnema/zut/packages/provider"
810 )
@@ -233,6 +235,101 @@ func TestAutoCompactContinuationIsHiddenFromTranscriptButPersisted(t *testing.T)
233235 }
234236 }
235237
238+func TestNewInteractiveRestoresValidCompactHandoff(t *testing.T) {
239+ interactive := NewInteractive(InteractiveConfig{
240+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
241+ })
242+ if got, want := interactive.compactContinuation, (compactContinuationState{reason: compactContinuationStatusRescue, rescueAttempts: 1}); got != want {
243+ t.Fatalf("restored compact handoff = %#v, want %#v", got, want)
244+ }
245+}
246+
247+func TestCancelTurnCancelsBeforePersistingCompactHandoff(t *testing.T) {
248+ persisted := make(chan string, 1)
249+ cancelled := make(chan struct{})
250+ persistenceStarted := make(chan struct{})
251+ releasePersistence := make(chan struct{})
252+ released := false
253+ defer func() {
254+ if !released {
255+ close(releasePersistence)
256+ }
257+ }()
258+
259+ interactive := NewInteractive(InteractiveConfig{
260+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
261+ PersistCompactHandoff: func(state json.RawMessage) error {
262+ close(persistenceStarted)
263+ <-releasePersistence
264+ persisted <- string(state)
265+ return nil
266+ },
267+ })
268+ interactive.mu.Lock()
269+ interactive.busy = true
270+ interactive.cancelTurn = func() { close(cancelled) }
271+ interactive.mu.Unlock()
272+
273+ done := make(chan struct{})
274+ go func() {
275+ interactive.CancelTurn()
276+ close(done)
277+ }()
278+
279+ select {
280+ case <-cancelled:
281+ case <-time.After(2 * time.Second):
282+ t.Fatal("cancellation waited for compact handoff persistence")
283+ }
284+ select {
285+ case <-persistenceStarted:
286+ case <-time.After(2 * time.Second):
287+ t.Fatal("cancellation did not persist the cleared compact handoff")
288+ }
289+ close(releasePersistence)
290+ released = true
291+ <-done
292+ select {
293+ case state := <-persisted:
294+ if state != "" {
295+ t.Fatalf("cleared compact handoff = %q, want empty", state)
296+ }
297+ case <-time.After(2 * time.Second):
298+ t.Fatal("cancellation did not finish compact handoff persistence")
299+ }
300+}
301+
302+func TestDecodeCompactHandoff(t *testing.T) {
303+ tests := []struct {
304+ name string
305+ raw string
306+ want compactContinuationState
307+ }{
308+ {
309+ name: "status rescue",
310+ raw: `{"version":1,"reason":"status_rescue","rescue_attempts":1}`,
311+ want: compactContinuationState{reason: compactContinuationStatusRescue, rescueAttempts: 1},
312+ },
313+ {
314+ name: "forced length",
315+ raw: `{"version":1,"reason":"forced_length"}`,
316+ want: compactContinuationState{reason: compactContinuationForcedLength},
317+ },
318+ {
319+ name: "invalid status attempt", raw: `{"version":1,"reason":"status_rescue","rescue_attempts":3}`},
320+ {name: "unknown reason", raw: `{"version":1,"reason":"other"}`},
321+ {name: "invalid JSON", raw: `{`},
322+ {name: "missing", raw: ""},
323+ }
324+ for _, tt := range tests {
325+ t.Run(tt.name, func(t *testing.T) {
326+ if got := decodeCompactHandoff([]byte(tt.raw)); got != tt.want {
327+ t.Fatalf("decodeCompactHandoff(%q) = %#v, want %#v", tt.raw, got, tt.want)
328+ }
329+ })
330+ }
331+}
332+
236333 func TestShouldAutoCompactUsesConfiguredThreshold(t *testing.T) {
237334 tests := []struct {
238335 name string
@@ -1,8 +1,10 @@
1 package modes1 package modes
2 2
3 import (3 import (
4+ "encoding/json"
4 "errors"5 "errors"
5 "testing"6 "testing"
7+ "time"
6 8
7 "github.com/bnema/zut/packages/provider"9 "github.com/bnema/zut/packages/provider"
8 )10 )
@@ -233,6 +235,101 @@ func TestAutoCompactContinuationIsHiddenFromTranscriptButPersisted(t *testing.T)
233 }235 }
234 }236 }
235 237
238+func TestNewInteractiveRestoresValidCompactHandoff(t *testing.T) {
239+ interactive := NewInteractive(InteractiveConfig{
240+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
241+ })
242+ if got, want := interactive.compactContinuation, (compactContinuationState{reason: compactContinuationStatusRescue, rescueAttempts: 1}); got != want {
243+ t.Fatalf("restored compact handoff = %#v, want %#v", got, want)
244+ }
245+}
246+
247+func TestCancelTurnCancelsBeforePersistingCompactHandoff(t *testing.T) {
248+ persisted := make(chan string, 1)
249+ cancelled := make(chan struct{})
250+ persistenceStarted := make(chan struct{})
251+ releasePersistence := make(chan struct{})
252+ released := false
253+ defer func() {
254+ if !released {
255+ close(releasePersistence)
256+ }
257+ }()
258+
259+ interactive := NewInteractive(InteractiveConfig{
260+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
261+ PersistCompactHandoff: func(state json.RawMessage) error {
262+ close(persistenceStarted)
263+ <-releasePersistence
264+ persisted <- string(state)
265+ return nil
266+ },
267+ })
268+ interactive.mu.Lock()
269+ interactive.busy = true
270+ interactive.cancelTurn = func() { close(cancelled) }
271+ interactive.mu.Unlock()
272+
273+ done := make(chan struct{})
274+ go func() {
275+ interactive.CancelTurn()
276+ close(done)
277+ }()
278+
279+ select {
280+ case <-cancelled:
281+ case <-time.After(2 * time.Second):
282+ t.Fatal("cancellation waited for compact handoff persistence")
283+ }
284+ select {
285+ case <-persistenceStarted:
286+ case <-time.After(2 * time.Second):
287+ t.Fatal("cancellation did not persist the cleared compact handoff")
288+ }
289+ close(releasePersistence)
290+ released = true
291+ <-done
292+ select {
293+ case state := <-persisted:
294+ if state != "" {
295+ t.Fatalf("cleared compact handoff = %q, want empty", state)
296+ }
297+ case <-time.After(2 * time.Second):
298+ t.Fatal("cancellation did not finish compact handoff persistence")
299+ }
300+}
301+
302+func TestDecodeCompactHandoff(t *testing.T) {
303+ tests := []struct {
304+ name string
305+ raw string
306+ want compactContinuationState
307+ }{
308+ {
309+ name: "status rescue",
310+ raw: `{"version":1,"reason":"status_rescue","rescue_attempts":1}`,
311+ want: compactContinuationState{reason: compactContinuationStatusRescue, rescueAttempts: 1},
312+ },
313+ {
314+ name: "forced length",
315+ raw: `{"version":1,"reason":"forced_length"}`,
316+ want: compactContinuationState{reason: compactContinuationForcedLength},
317+ },
318+ {
319+ name: "invalid status attempt", raw: `{"version":1,"reason":"status_rescue","rescue_attempts":3}`},
320+ {name: "unknown reason", raw: `{"version":1,"reason":"other"}`},
321+ {name: "invalid JSON", raw: `{`},
322+ {name: "missing", raw: ""},
323+ }
324+ for _, tt := range tests {
325+ t.Run(tt.name, func(t *testing.T) {
326+ if got := decodeCompactHandoff([]byte(tt.raw)); got != tt.want {
327+ t.Fatalf("decodeCompactHandoff(%q) = %#v, want %#v", tt.raw, got, tt.want)
328+ }
329+ })
330+ }
331+}
332+
236 func TestShouldAutoCompactUsesConfiguredThreshold(t *testing.T) {333 func TestShouldAutoCompactUsesConfiguredThreshold(t *testing.T) {
237 tests := []struct {334 tests := []struct {
238 name string335 name string
M packages/agent/modes/compact_queue_test.go +353 -0
@@ -2,6 +2,7 @@ package modes
22
33 import (
44 "context"
5+ "encoding/json"
56 "errors"
67 "sync"
78 "testing"
@@ -257,6 +258,340 @@ 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+type heldCompletionClient struct {
297+ started chan struct{}
298+ release chan struct{}
299+}
300+
301+func (c *heldCompletionClient) Name() string { return "held-completion-test" }
302+
303+func (c *heldCompletionClient) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Event, error) {
304+ out := make(chan provider.Event, 1)
305+ go func() {
306+ defer close(out)
307+ close(c.started)
308+ select {
309+ case <-ctx.Done():
310+ out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()}
311+ case <-c.release:
312+ out <- provider.EventDone{
313+ Stop: provider.StopEnd,
314+ Message: provider.Message{
315+ Role: provider.RoleAssistant,
316+ Content: []provider.Content{provider.TextBlock{Text: "Next I will inspect the remaining call sites."}},
317+ },
318+ }
319+ }
320+ }()
321+ return out, nil
322+}
323+
324+func TestRestoredStatusHandoffContinuesExistingPromptWithoutDuplication(t *testing.T) {
325+ client := &restoredHandoffClient{
326+ requests: make(chan provider.Request, 2),
327+ replies: []string{"Next I will run the targeted tests.", "All required work is complete."},
328+ }
329+ agent := core.NewAgent(client, "test-model", "", nil)
330+ agent.SetMessages([]provider.Message{{
331+ Role: provider.RoleUser,
332+ Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
333+ Meta: map[string]string{autoCompactContinueMetaKey: "true"},
334+ }})
335+ interactive := NewInteractive(InteractiveConfig{
336+ Agent: agent,
337+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
338+ })
339+ interactive.runCtx = context.Background()
340+
341+ interactive.startRestoredCompactHandoff(context.Background())
342+ for attempt := 1; attempt <= 2; attempt++ {
343+ select {
344+ case req := <-client.requests:
345+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != attempt {
346+ t.Fatalf("request %d continuation prompt count = %d, want %d: %#v", attempt, got, attempt, req.Messages)
347+ }
348+ case <-time.After(2 * time.Second):
349+ t.Fatalf("restored handoff request %d did not start", attempt)
350+ }
351+ }
352+ waitInteractiveIdle(t, interactive)
353+}
354+
355+func TestRestoredHandoffAppendsMissingContinuationPrompt(t *testing.T) {
356+ client := &restoredHandoffClient{requests: make(chan provider.Request, 1)}
357+ agent := core.NewAgent(client, "test-model", "", nil)
358+ interactive := NewInteractive(InteractiveConfig{
359+ Agent: agent,
360+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"structural_tail"}`),
361+ })
362+ interactive.runCtx = context.Background()
363+
364+ interactive.startRestoredCompactHandoff(context.Background())
365+ select {
366+ case req := <-client.requests:
367+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
368+ t.Fatalf("restored request continuation prompt count = %d, want 1: %#v", got, req.Messages)
369+ }
370+ case <-time.After(2 * time.Second):
371+ t.Fatal("restored handoff did not append a missing continuation prompt")
372+ }
373+ waitInteractiveIdle(t, interactive)
374+}
375+
376+func TestRestoredHandoffAfterFinalReplyIsDiscarded(t *testing.T) {
377+ persisted := make(chan string, 1)
378+ client := &restoredHandoffClient{requests: make(chan provider.Request, 1)}
379+ agent := core.NewAgent(client, "test-model", "", nil)
380+ agent.SetMessages([]provider.Message{
381+ {
382+ Role: provider.RoleUser,
383+ Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
384+ Meta: map[string]string{autoCompactContinueMetaKey: "true"},
385+ },
386+ {
387+ Role: provider.RoleAssistant,
388+ Content: []provider.Content{provider.TextBlock{Text: "All required work is complete."}},
389+ },
390+ })
391+ interactive := NewInteractive(InteractiveConfig{
392+ Agent: agent,
393+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
394+ PersistCompactHandoff: func(state json.RawMessage) error {
395+ select {
396+ case persisted <- string(state):
397+ default:
398+ }
399+ return nil
400+ },
401+ })
402+
403+ interactive.startRestoredCompactHandoff(context.Background())
404+ interactive.mu.Lock()
405+ busy := interactive.busy
406+ interactive.mu.Unlock()
407+ if busy {
408+ t.Fatal("discarded restored handoff left the turn slot reserved")
409+ }
410+ select {
411+ case state := <-persisted:
412+ if state != "" {
413+ t.Fatalf("cleared handoff = %q, want empty", state)
414+ }
415+ case <-time.After(2 * time.Second):
416+ t.Fatal("stale handoff was not cleared")
417+ }
418+ select {
419+ case req := <-client.requests:
420+ t.Fatalf("stale handoff started a provider request: %#v", req.Messages)
421+ default:
422+ }
423+}
424+
425+func TestRestoredHandoffReservesTurnBeforePersistence(t *testing.T) {
426+ client := &restoredHandoffClient{
427+ requests: make(chan provider.Request, 1),
428+ replies: []string{"All required work is complete."},
429+ }
430+ agent := core.NewAgent(client, "test-model", "", nil)
431+ agent.SetMessages([]provider.Message{{
432+ Role: provider.RoleUser,
433+ Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
434+ Meta: map[string]string{autoCompactContinueMetaKey: "true"},
435+ }})
436+ persistenceStarted := make(chan struct{})
437+ releasePersistence := make(chan struct{})
438+ released := false
439+ defer func() {
440+ if !released {
441+ close(releasePersistence)
442+ }
443+ }()
444+ interactive := NewInteractive(InteractiveConfig{
445+ Agent: agent,
446+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
447+ PersistCompactHandoff: func(json.RawMessage) error {
448+ close(persistenceStarted)
449+ <-releasePersistence
450+ return nil
451+ },
452+ })
453+ interactive.runCtx = context.Background()
454+ interactive.mu.Lock()
455+ interactive.queued = []string{"newer user request"}
456+ interactive.mu.Unlock()
457+
458+ done := make(chan struct{})
459+ go func() {
460+ interactive.startRestoredCompactHandoff(context.Background())
461+ close(done)
462+ }()
463+ select {
464+ case <-persistenceStarted:
465+ case <-time.After(2 * time.Second):
466+ t.Fatal("restored handoff did not reach persistence")
467+ }
468+ interactive.mu.Lock()
469+ busy := interactive.busy
470+ interactive.mu.Unlock()
471+ if !busy {
472+ t.Fatal("restored handoff did not reserve the turn slot before persistence")
473+ }
474+ close(releasePersistence)
475+ released = true
476+ <-done
477+ select {
478+ case <-client.requests:
479+ case <-time.After(2 * time.Second):
480+ t.Fatal("restored handoff did not continue after persistence")
481+ }
482+ waitInteractiveIdle(t, interactive)
483+}
484+
485+func TestStatusRescueClearsHandoffWhenAgentIsRemovedDuringTurn(t *testing.T) {
486+ client := &heldCompletionClient{started: make(chan struct{}), release: make(chan struct{})}
487+ agent := core.NewAgent(client, "test-model", "", nil)
488+ agent.SetMessages([]provider.Message{{
489+ Role: provider.RoleUser,
490+ Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
491+ Meta: map[string]string{autoCompactContinueMetaKey: "true"},
492+ }})
493+ persisted := make(chan string, 1)
494+ interactive := NewInteractive(InteractiveConfig{
495+ Agent: agent,
496+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
497+ PersistCompactHandoff: func(state json.RawMessage) error {
498+ select {
499+ case persisted <- string(state):
500+ default:
501+ }
502+ return nil
503+ },
504+ })
505+ interactive.runCtx = context.Background()
506+
507+ interactive.startTurnRequest(context.Background(), "", nil, true, false)
508+ select {
509+ case <-client.started:
510+ case <-time.After(2 * time.Second):
511+ t.Fatal("continuation did not start")
512+ }
513+ interactive.mu.Lock()
514+ interactive.agent = nil
515+ interactive.mu.Unlock()
516+ close(client.release)
517+
518+ select {
519+ case state := <-persisted:
520+ if state != "" {
521+ t.Fatalf("cleared handoff = %q, want empty", state)
522+ }
523+ case <-time.After(2 * time.Second):
524+ t.Fatal("agent removal did not clear the persisted handoff")
525+ }
526+ waitInteractiveIdle(t, interactive)
527+}
528+
529+func TestRestoredHandoffDefersToQueuedUserPrompt(t *testing.T) {
530+ client := &restoredHandoffClient{requests: make(chan provider.Request, 1)}
531+ agent := core.NewAgent(client, "test-model", "", nil)
532+ agent.SetMessages([]provider.Message{{
533+ Role: provider.RoleUser,
534+ Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
535+ Meta: map[string]string{autoCompactContinueMetaKey: "true"},
536+ }})
537+ interactive := NewInteractive(InteractiveConfig{
538+ Agent: agent,
539+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
540+ })
541+ interactive.runCtx = context.Background()
542+ interactive.mu.Lock()
543+ interactive.queued = []string{"newer user request"}
544+ interactive.mu.Unlock()
545+
546+ interactive.startRestoredCompactHandoff(context.Background())
547+ select {
548+ case req := <-client.requests:
549+ if got := requestUserTextCount(req, "newer user request"); got != 1 {
550+ t.Fatalf("newer user request count = %d, want 1: %#v", got, req.Messages)
551+ }
552+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
553+ t.Fatalf("restored continuation prompt count = %d, want only the existing prompt: %#v", got, req.Messages)
554+ }
555+ case <-time.After(2 * time.Second):
556+ t.Fatal("queued user prompt did not run instead of restored handoff")
557+ }
558+ waitInteractiveIdle(t, interactive)
559+}
560+
561+func TestRestoredExhaustedStatusHandoffDoesNotContinueAgain(t *testing.T) {
562+ client := &restoredHandoffClient{
563+ requests: make(chan provider.Request, 2),
564+ replies: []string{"Next I will run the targeted tests."},
565+ }
566+ agent := core.NewAgent(client, "test-model", "", nil)
567+ agent.SetMessages([]provider.Message{{
568+ Role: provider.RoleUser,
569+ Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
570+ Meta: map[string]string{autoCompactContinueMetaKey: "true"},
571+ }})
572+ interactive := NewInteractive(InteractiveConfig{
573+ Agent: agent,
574+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":2}`),
575+ })
576+ interactive.runCtx = context.Background()
577+
578+ interactive.startRestoredCompactHandoff(context.Background())
579+ select {
580+ case req := <-client.requests:
581+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
582+ t.Fatalf("restored request continuation prompt count = %d, want 1: %#v", got, req.Messages)
583+ }
584+ case <-time.After(2 * time.Second):
585+ t.Fatal("restored handoff did not start")
586+ }
587+ waitInteractiveIdle(t, interactive)
588+ select {
589+ case req := <-client.requests:
590+ t.Fatalf("exhausted handoff started another request: %#v", req.Messages)
591+ default:
592+ }
593+}
594+
260595 func TestThresholdAutoCompactionContinuesMostRecentIntent(t *testing.T) {
261596 client := &thresholdAutoCompactClient{
262597 compactionStarted: make(chan struct{}),
@@ -292,6 +627,7 @@ func TestThresholdAutoCompactionContinuesMostRecentIntent(t *testing.T) {
292627 }
293628
294629 func TestThresholdAutoCompactionRescuesStatusAfterCompaction(t *testing.T) {
630+ persistedHandoff := make(chan string, 1)
295631 client := &thresholdAutoCompactClient{
296632 compactionStarted: make(chan struct{}),
297633 releaseCompaction: make(chan struct{}),
@@ -307,6 +643,13 @@ func TestThresholdAutoCompactionRescuesStatusAfterCompaction(t *testing.T) {
307643 Provider: "anthropic",
308644 Model: "claude-sonnet-4-5-20250929",
309645 AutoCompactThreshold: &threshold,
646+ PersistCompactHandoff: func(state json.RawMessage) error {
647+ select {
648+ case persistedHandoff <- string(state):
649+ default:
650+ }
651+ return nil
652+ },
310653 })
311654 interactive.runCtx = context.Background()
312655
@@ -318,6 +661,16 @@ func TestThresholdAutoCompactionRescuesStatusAfterCompaction(t *testing.T) {
318661 }
319662 close(client.releaseCompaction)
320663
664+ select {
665+ case state := <-persistedHandoff:
666+ want := `{"version":1,"reason":"status_rescue","rescue_attempts":1}`
667+ if state != want {
668+ t.Fatalf("persisted handoff = %q, want %q", state, want)
669+ }
670+ case <-time.After(2 * time.Second):
671+ t.Fatal("status rescue handoff was not persisted")
672+ }
673+
321674 select {
322675 case req := <-client.followUpRequest:
323676 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,340 @@ 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+type heldCompletionClient struct {
297+ started chan struct{}
298+ release chan struct{}
299+}
300+
301+func (c *heldCompletionClient) Name() string { return "held-completion-test" }
302+
303+func (c *heldCompletionClient) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Event, error) {
304+ out := make(chan provider.Event, 1)
305+ go func() {
306+ defer close(out)
307+ close(c.started)
308+ select {
309+ case <-ctx.Done():
310+ out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()}
311+ case <-c.release:
312+ out <- provider.EventDone{
313+ Stop: provider.StopEnd,
314+ Message: provider.Message{
315+ Role: provider.RoleAssistant,
316+ Content: []provider.Content{provider.TextBlock{Text: "Next I will inspect the remaining call sites."}},
317+ },
318+ }
319+ }
320+ }()
321+ return out, nil
322+}
323+
324+func TestRestoredStatusHandoffContinuesExistingPromptWithoutDuplication(t *testing.T) {
325+ client := &restoredHandoffClient{
326+ requests: make(chan provider.Request, 2),
327+ replies: []string{"Next I will run the targeted tests.", "All required work is complete."},
328+ }
329+ agent := core.NewAgent(client, "test-model", "", nil)
330+ agent.SetMessages([]provider.Message{{
331+ Role: provider.RoleUser,
332+ Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
333+ Meta: map[string]string{autoCompactContinueMetaKey: "true"},
334+ }})
335+ interactive := NewInteractive(InteractiveConfig{
336+ Agent: agent,
337+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
338+ })
339+ interactive.runCtx = context.Background()
340+
341+ interactive.startRestoredCompactHandoff(context.Background())
342+ for attempt := 1; attempt <= 2; attempt++ {
343+ select {
344+ case req := <-client.requests:
345+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != attempt {
346+ t.Fatalf("request %d continuation prompt count = %d, want %d: %#v", attempt, got, attempt, req.Messages)
347+ }
348+ case <-time.After(2 * time.Second):
349+ t.Fatalf("restored handoff request %d did not start", attempt)
350+ }
351+ }
352+ waitInteractiveIdle(t, interactive)
353+}
354+
355+func TestRestoredHandoffAppendsMissingContinuationPrompt(t *testing.T) {
356+ client := &restoredHandoffClient{requests: make(chan provider.Request, 1)}
357+ agent := core.NewAgent(client, "test-model", "", nil)
358+ interactive := NewInteractive(InteractiveConfig{
359+ Agent: agent,
360+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"structural_tail"}`),
361+ })
362+ interactive.runCtx = context.Background()
363+
364+ interactive.startRestoredCompactHandoff(context.Background())
365+ select {
366+ case req := <-client.requests:
367+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
368+ t.Fatalf("restored request continuation prompt count = %d, want 1: %#v", got, req.Messages)
369+ }
370+ case <-time.After(2 * time.Second):
371+ t.Fatal("restored handoff did not append a missing continuation prompt")
372+ }
373+ waitInteractiveIdle(t, interactive)
374+}
375+
376+func TestRestoredHandoffAfterFinalReplyIsDiscarded(t *testing.T) {
377+ persisted := make(chan string, 1)
378+ client := &restoredHandoffClient{requests: make(chan provider.Request, 1)}
379+ agent := core.NewAgent(client, "test-model", "", nil)
380+ agent.SetMessages([]provider.Message{
381+ {
382+ Role: provider.RoleUser,
383+ Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
384+ Meta: map[string]string{autoCompactContinueMetaKey: "true"},
385+ },
386+ {
387+ Role: provider.RoleAssistant,
388+ Content: []provider.Content{provider.TextBlock{Text: "All required work is complete."}},
389+ },
390+ })
391+ interactive := NewInteractive(InteractiveConfig{
392+ Agent: agent,
393+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
394+ PersistCompactHandoff: func(state json.RawMessage) error {
395+ select {
396+ case persisted <- string(state):
397+ default:
398+ }
399+ return nil
400+ },
401+ })
402+
403+ interactive.startRestoredCompactHandoff(context.Background())
404+ interactive.mu.Lock()
405+ busy := interactive.busy
406+ interactive.mu.Unlock()
407+ if busy {
408+ t.Fatal("discarded restored handoff left the turn slot reserved")
409+ }
410+ select {
411+ case state := <-persisted:
412+ if state != "" {
413+ t.Fatalf("cleared handoff = %q, want empty", state)
414+ }
415+ case <-time.After(2 * time.Second):
416+ t.Fatal("stale handoff was not cleared")
417+ }
418+ select {
419+ case req := <-client.requests:
420+ t.Fatalf("stale handoff started a provider request: %#v", req.Messages)
421+ default:
422+ }
423+}
424+
425+func TestRestoredHandoffReservesTurnBeforePersistence(t *testing.T) {
426+ client := &restoredHandoffClient{
427+ requests: make(chan provider.Request, 1),
428+ replies: []string{"All required work is complete."},
429+ }
430+ agent := core.NewAgent(client, "test-model", "", nil)
431+ agent.SetMessages([]provider.Message{{
432+ Role: provider.RoleUser,
433+ Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
434+ Meta: map[string]string{autoCompactContinueMetaKey: "true"},
435+ }})
436+ persistenceStarted := make(chan struct{})
437+ releasePersistence := make(chan struct{})
438+ released := false
439+ defer func() {
440+ if !released {
441+ close(releasePersistence)
442+ }
443+ }()
444+ interactive := NewInteractive(InteractiveConfig{
445+ Agent: agent,
446+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
447+ PersistCompactHandoff: func(json.RawMessage) error {
448+ close(persistenceStarted)
449+ <-releasePersistence
450+ return nil
451+ },
452+ })
453+ interactive.runCtx = context.Background()
454+ interactive.mu.Lock()
455+ interactive.queued = []string{"newer user request"}
456+ interactive.mu.Unlock()
457+
458+ done := make(chan struct{})
459+ go func() {
460+ interactive.startRestoredCompactHandoff(context.Background())
461+ close(done)
462+ }()
463+ select {
464+ case <-persistenceStarted:
465+ case <-time.After(2 * time.Second):
466+ t.Fatal("restored handoff did not reach persistence")
467+ }
468+ interactive.mu.Lock()
469+ busy := interactive.busy
470+ interactive.mu.Unlock()
471+ if !busy {
472+ t.Fatal("restored handoff did not reserve the turn slot before persistence")
473+ }
474+ close(releasePersistence)
475+ released = true
476+ <-done
477+ select {
478+ case <-client.requests:
479+ case <-time.After(2 * time.Second):
480+ t.Fatal("restored handoff did not continue after persistence")
481+ }
482+ waitInteractiveIdle(t, interactive)
483+}
484+
485+func TestStatusRescueClearsHandoffWhenAgentIsRemovedDuringTurn(t *testing.T) {
486+ client := &heldCompletionClient{started: make(chan struct{}), release: make(chan struct{})}
487+ agent := core.NewAgent(client, "test-model", "", nil)
488+ agent.SetMessages([]provider.Message{{
489+ Role: provider.RoleUser,
490+ Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
491+ Meta: map[string]string{autoCompactContinueMetaKey: "true"},
492+ }})
493+ persisted := make(chan string, 1)
494+ interactive := NewInteractive(InteractiveConfig{
495+ Agent: agent,
496+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
497+ PersistCompactHandoff: func(state json.RawMessage) error {
498+ select {
499+ case persisted <- string(state):
500+ default:
501+ }
502+ return nil
503+ },
504+ })
505+ interactive.runCtx = context.Background()
506+
507+ interactive.startTurnRequest(context.Background(), "", nil, true, false)
508+ select {
509+ case <-client.started:
510+ case <-time.After(2 * time.Second):
511+ t.Fatal("continuation did not start")
512+ }
513+ interactive.mu.Lock()
514+ interactive.agent = nil
515+ interactive.mu.Unlock()
516+ close(client.release)
517+
518+ select {
519+ case state := <-persisted:
520+ if state != "" {
521+ t.Fatalf("cleared handoff = %q, want empty", state)
522+ }
523+ case <-time.After(2 * time.Second):
524+ t.Fatal("agent removal did not clear the persisted handoff")
525+ }
526+ waitInteractiveIdle(t, interactive)
527+}
528+
529+func TestRestoredHandoffDefersToQueuedUserPrompt(t *testing.T) {
530+ client := &restoredHandoffClient{requests: make(chan provider.Request, 1)}
531+ agent := core.NewAgent(client, "test-model", "", nil)
532+ agent.SetMessages([]provider.Message{{
533+ Role: provider.RoleUser,
534+ Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
535+ Meta: map[string]string{autoCompactContinueMetaKey: "true"},
536+ }})
537+ interactive := NewInteractive(InteractiveConfig{
538+ Agent: agent,
539+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`),
540+ })
541+ interactive.runCtx = context.Background()
542+ interactive.mu.Lock()
543+ interactive.queued = []string{"newer user request"}
544+ interactive.mu.Unlock()
545+
546+ interactive.startRestoredCompactHandoff(context.Background())
547+ select {
548+ case req := <-client.requests:
549+ if got := requestUserTextCount(req, "newer user request"); got != 1 {
550+ t.Fatalf("newer user request count = %d, want 1: %#v", got, req.Messages)
551+ }
552+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
553+ t.Fatalf("restored continuation prompt count = %d, want only the existing prompt: %#v", got, req.Messages)
554+ }
555+ case <-time.After(2 * time.Second):
556+ t.Fatal("queued user prompt did not run instead of restored handoff")
557+ }
558+ waitInteractiveIdle(t, interactive)
559+}
560+
561+func TestRestoredExhaustedStatusHandoffDoesNotContinueAgain(t *testing.T) {
562+ client := &restoredHandoffClient{
563+ requests: make(chan provider.Request, 2),
564+ replies: []string{"Next I will run the targeted tests."},
565+ }
566+ agent := core.NewAgent(client, "test-model", "", nil)
567+ agent.SetMessages([]provider.Message{{
568+ Role: provider.RoleUser,
569+ Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}},
570+ Meta: map[string]string{autoCompactContinueMetaKey: "true"},
571+ }})
572+ interactive := NewInteractive(InteractiveConfig{
573+ Agent: agent,
574+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":2}`),
575+ })
576+ interactive.runCtx = context.Background()
577+
578+ interactive.startRestoredCompactHandoff(context.Background())
579+ select {
580+ case req := <-client.requests:
581+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
582+ t.Fatalf("restored request continuation prompt count = %d, want 1: %#v", got, req.Messages)
583+ }
584+ case <-time.After(2 * time.Second):
585+ t.Fatal("restored handoff did not start")
586+ }
587+ waitInteractiveIdle(t, interactive)
588+ select {
589+ case req := <-client.requests:
590+ t.Fatalf("exhausted handoff started another request: %#v", req.Messages)
591+ default:
592+ }
593+}
594+
260 func TestThresholdAutoCompactionContinuesMostRecentIntent(t *testing.T) {595 func TestThresholdAutoCompactionContinuesMostRecentIntent(t *testing.T) {
261 client := &thresholdAutoCompactClient{596 client := &thresholdAutoCompactClient{
262 compactionStarted: make(chan struct{}),597 compactionStarted: make(chan struct{}),
@@ -292,6 +627,7 @@ func TestThresholdAutoCompactionContinuesMostRecentIntent(t *testing.T) {
292 }627 }
293 628
294 func TestThresholdAutoCompactionRescuesStatusAfterCompaction(t *testing.T) {629 func TestThresholdAutoCompactionRescuesStatusAfterCompaction(t *testing.T) {
630+ persistedHandoff := make(chan string, 1)
295 client := &thresholdAutoCompactClient{631 client := &thresholdAutoCompactClient{
296 compactionStarted: make(chan struct{}),632 compactionStarted: make(chan struct{}),
297 releaseCompaction: make(chan struct{}),633 releaseCompaction: make(chan struct{}),
@@ -307,6 +643,13 @@ func TestThresholdAutoCompactionRescuesStatusAfterCompaction(t *testing.T) {
307 Provider: "anthropic",643 Provider: "anthropic",
308 Model: "claude-sonnet-4-5-20250929",644 Model: "claude-sonnet-4-5-20250929",
309 AutoCompactThreshold: &threshold,645 AutoCompactThreshold: &threshold,
646+ PersistCompactHandoff: func(state json.RawMessage) error {
647+ select {
648+ case persistedHandoff <- string(state):
649+ default:
650+ }
651+ return nil
652+ },
310 })653 })
311 interactive.runCtx = context.Background()654 interactive.runCtx = context.Background()
312 655
@@ -318,6 +661,16 @@ func TestThresholdAutoCompactionRescuesStatusAfterCompaction(t *testing.T) {
318 }661 }
319 close(client.releaseCompaction)662 close(client.releaseCompaction)
320 663
664+ select {
665+ case state := <-persistedHandoff:
666+ want := `{"version":1,"reason":"status_rescue","rescue_attempts":1}`
667+ if state != want {
668+ t.Fatalf("persisted handoff = %q, want %q", state, want)
669+ }
670+ case <-time.After(2 * time.Second):
671+ t.Fatal("status rescue handoff was not persisted")
672+ }
673+
321 select {674 select {
322 case req := <-client.followUpRequest:675 case req := <-client.followUpRequest:
323 if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {676 if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
M packages/agent/modes/interactive.go +436 -86
@@ -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,8 +3153,10 @@ 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()
30933162 if busyCancel {
@@ -3096,6 +3165,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
30963165 // goroutine unblocks and the context cancellation can
30973166 // actually take effect.
30983167 i.confirmDialog.CancelAll("turn cancelled")
3168+ if persistHandoff {
3169+ i.persistCompactHandoff(handoff)
3170+ }
30993171 return false
31003172 }
31013173 case tui.KeyCtrlD:
@@ -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,10 +4034,13 @@ 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()
39514039 cancel()
39524040 i.confirmDialog.CancelAll("turn cancelled")
4041+ if persistHandoff {
4042+ i.persistCompactHandoff(handoff)
4043+ }
39534044 }
39544045 }
39554046
@@ -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,9 +6411,12 @@ 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()
63116416 cancel()
6417+ if persistHandoff {
6418+ i.persistCompactHandoff(handoff)
6419+ }
63126420 }
63136421 // ConfirmToolCall waits on its response channel rather than the turn
63146422 // context, so cancellation must also resolve every pending prompt.
@@ -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,179 @@ 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
6940+// autoCompactContinuationPrompt is an interactive-only compact-handoff
6941+// instruction. Its completion criteria must stay separate from Ponytail so
6942+// toggling the optional coding-style addendum never changes recovery behavior.
67576943 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.`
67586944
6945+type compactHandoffResume uint8
6946+
6947+const (
6948+ compactHandoffDiscard compactHandoffResume = iota
6949+ compactHandoffContinueExisting
6950+ compactHandoffAppendPrompt
6951+)
6952+
6953+// classifyCompactHandoffResume validates that the persisted checkpoint still
6954+// describes the effective transcript. A normal assistant reply or a newer
6955+// explicit user message supersedes the checkpoint; tool activity remains an
6956+// unfinished existing turn. No hidden prompt means the process stopped after
6957+// recording the handoff but before appending its context message.
6958+func classifyCompactHandoffResume(messages []provider.Message) compactHandoffResume {
6959+ lastHandoff := -1
6960+ for idx := len(messages) - 1; idx >= 0; idx-- {
6961+ message := messages[idx]
6962+ if message.Role == provider.RoleUser && message.Meta[autoCompactContinueMetaKey] == "true" {
6963+ lastHandoff = idx
6964+ break
6965+ }
6966+ }
6967+ if lastHandoff < 0 {
6968+ return compactHandoffAppendPrompt
6969+ }
6970+ for _, message := range messages[lastHandoff+1:] {
6971+ switch message.Role {
6972+ case provider.RoleTool:
6973+ continue
6974+ case provider.RoleAssistant:
6975+ hasToolCall := false
6976+ for _, content := range message.Content {
6977+ if _, ok := content.(provider.ToolCallBlock); ok {
6978+ hasToolCall = true
6979+ break
6980+ }
6981+ }
6982+ if !hasToolCall {
6983+ return compactHandoffDiscard
6984+ }
6985+ default:
6986+ return compactHandoffDiscard
6987+ }
6988+ }
6989+ return compactHandoffContinueExisting
6990+}
6991+
6992+// startRestoredCompactHandoff resumes a handoff checkpoint restored from the
6993+// active session. When its hidden context message was already persisted, the
6994+// agent continues that existing turn rather than appending it again.
6995+func (i *Interactive) startRestoredCompactHandoff(parent context.Context) {
6996+ if parent == nil {
6997+ parent = i.runCtx
6998+ }
6999+ if parent == nil {
7000+ parent = context.Background()
7001+ }
7002+ i.mu.Lock()
7003+ state := i.compactContinuation
7004+ ag := i.agent
7005+ busy := i.busy || i.compacting || i.autoCompacting || i.shellRunning
7006+ if state.reason == compactContinuationNone || ag == nil || busy {
7007+ i.mu.Unlock()
7008+ return
7009+ }
7010+
7011+ var next string
7012+ continueQueued := false
7013+ var resume compactHandoffResume
7014+ var handoff json.RawMessage
7015+ var persistHandoff bool
7016+ switch {
7017+ case state.reason != compactContinuationForcedLength && len(i.queued) > 0:
7018+ next, i.queued = i.queued[0], i.queued[1:]
7019+ handoff, persistHandoff = i.resetCompactContinuationLocked()
7020+ case state.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0:
7021+ continueQueued = true
7022+ handoff, persistHandoff = i.resetCompactContinuationLocked()
7023+ default:
7024+ resume = classifyCompactHandoffResume(ag.Messages())
7025+ if resume == compactHandoffDiscard {
7026+ handoff, persistHandoff = i.resetCompactContinuationLocked()
7027+ }
7028+ }
7029+ // Reserve the turn slot before persisting or dispatching so concurrent input
7030+ // can only queue behind the restored handoff, never start a competing turn.
7031+ starting := next != "" || continueQueued || resume == compactHandoffContinueExisting || resume == compactHandoffAppendPrompt
7032+ i.busy = starting
7033+ i.mu.Unlock()
7034+ if persistHandoff {
7035+ i.persistCompactHandoff(handoff)
7036+ }
7037+ if !starting {
7038+ i.invalidate()
7039+ return
7040+ }
7041+ switch {
7042+ case next != "":
7043+ i.startTurn(parent, next)
7044+ case continueQueued:
7045+ i.startTurnRequest(parent, "", nil, true, false)
7046+ case resume == compactHandoffContinueExisting:
7047+ i.startTurnRequest(parent, "", nil, true, false)
7048+ case resume == compactHandoffAppendPrompt:
7049+ i.startAutoCompactContinuation(parent)
7050+ }
7051+}
7052+
67597053 // startAutoCompactContinuation adds an internal user turn that tells the
67607054 // model to resume the task after threshold compaction. Provider requests
67617055 // cannot continue from an assistant tail on every supported API, so the
@@ -6779,14 +7073,20 @@ func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
67797073 if i.compactContinuation.reason != compactContinuationForcedLength && len(i.queued) > 0 {
67807074 next := i.queued[0]
67817075 i.queued = i.queued[1:]
6782- i.resetCompactContinuationLocked()
7076+ handoff, persistHandoff := i.resetCompactContinuationLocked()
67837077 i.mu.Unlock()
7078+ if persistHandoff {
7079+ i.persistCompactHandoff(handoff)
7080+ }
67847081 i.startTurn(parent, next)
67857082 return
67867083 }
67877084 if i.compactContinuation.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0 {
6788- i.resetCompactContinuationLocked()
7085+ handoff, persistHandoff := i.resetCompactContinuationLocked()
67897086 i.mu.Unlock()
7087+ if persistHandoff {
7088+ i.persistCompactHandoff(handoff)
7089+ }
67907090 i.startTurnRequest(parent, "", nil, true, false)
67917091 return
67927092 }
@@ -6827,8 +7127,10 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
68277127 i.activity.kind = activityCompactingHistory
68287128 }
68297129 i.cancelTurn = cancel
7130+ var initialHandoff json.RawMessage
7131+ persistInitialHandoff := false
68307132 if request.origin == compactOriginManual || request.origin == compactOriginRecovery {
6831- i.resetCompactContinuationLocked()
7133+ initialHandoff, persistInitialHandoff = i.resetCompactContinuationLocked()
68327134 }
68337135 i.statusErr = ""
68347136 i.statusOK = ""
@@ -6838,6 +7140,9 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
68387140 i.scrollOffset = 0
68397141 i.helpBlock = nil
68407142 i.mu.Unlock()
7143+ if persistInitialHandoff {
7144+ i.persistCompactHandoff(initialHandoff)
7145+ }
68417146 i.invalidate()
68427147
68437148 go func() {
@@ -6880,6 +7185,8 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
68807185 var hasNext bool
68817186 var continueExisting bool
68827187 var continueAutomatically bool
7188+ var handoff json.RawMessage
7189+ var persistHandoff bool
68837190
68847191 switch {
68857192 case err != nil && ctx.Err() != nil:
@@ -6891,7 +7198,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
68917198 }
68927199 i.queued = nil // drop queue on cancel
68937200 i.clearPendingCompactTurnLocked()
6894- i.resetCompactContinuationLocked()
7201+ handoff, persistHandoff = i.resetCompactContinuationLocked()
68957202 if i.agent != nil {
68967203 i.agent.DrainQueuedMessages()
68977204 }
@@ -6900,7 +7207,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
69007207 i.statusOK = ""
69017208 i.queued = nil // drop queue on error
69027209 i.clearPendingCompactTurnLocked()
6903- i.resetCompactContinuationLocked()
7210+ handoff, persistHandoff = i.resetCompactContinuationLocked()
69047211 if i.agent != nil {
69057212 i.agent.DrainQueuedMessages()
69067213 }
@@ -6941,12 +7248,12 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
69417248 case continuationReason == compactContinuationForcedLength:
69427249 // Forced truncated-output continuation keeps its existing
69437250 // priority over an explicitly queued prompt.
6944- i.compactContinuation = compactContinuationState{reason: continuationReason}
7251+ handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
69457252 continueAutomatically = true
69467253 case len(i.queued) > 0:
69477254 next, i.queued = i.queued[0], i.queued[1:]
69487255 hasNext = true
6949- i.resetCompactContinuationLocked()
7256+ handoff, persistHandoff = i.resetCompactContinuationLocked()
69507257 case continuationReason == compactContinuationStructuralTail || continuationReason == compactContinuationStatusRescue:
69517258 if continuationReason == compactContinuationStatusRescue {
69527259 attempts := 1
@@ -6954,19 +7261,19 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
69547261 attempts = i.compactContinuation.rescueAttempts + 1
69557262 }
69567263 if attempts > maxStatusRescueContinuations {
6957- i.resetCompactContinuationLocked()
7264+ handoff, persistHandoff = i.resetCompactContinuationLocked()
69587265 break
69597266 }
6960- i.compactContinuation = compactContinuationState{
7267+ handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{
69617268 reason: continuationReason,
69627269 rescueAttempts: attempts,
6963- }
7270+ })
69647271 } else {
6965- i.compactContinuation = compactContinuationState{reason: continuationReason}
7272+ handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
69667273 }
69677274 continueAutomatically = true
69687275 default:
6969- i.resetCompactContinuationLocked()
7276+ handoff, persistHandoff = i.resetCompactContinuationLocked()
69707277 }
69717278 }
69727279 // Keep the host busy until the hand-off decision is committed under
@@ -6977,6 +7284,9 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
69777284 i.compacting = false
69787285 i.autoCompacting = false
69797286 i.mu.Unlock()
7287+ if persistHandoff {
7288+ i.persistCompactHandoff(handoff)
7289+ }
69807290 runPendingIdleWork(pendingIdleWork)
69817291 i.invalidate()
69827292
@@ -7156,8 +7466,10 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
71567466 // The condense flow re-fires the user's queued prompt for us, so
71577467 // we just hand it off and exit.
71587468 i.mu.Lock()
7469+ var resetHandoff json.RawMessage
7470+ var persistResetHandoff bool
71597471 if !continueExisting {
7160- i.resetCompactContinuationLocked()
7472+ resetHandoff, persistResetHandoff = i.resetCompactContinuationLocked()
71617473 }
71627474 needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()
71637475 if needsPreCompact {
@@ -7178,11 +7490,17 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
71787490 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))
71797491 i.pendingPostCompactNote = "context auto-compacted; sending your last message"
71807492 i.mu.Unlock()
7493+ if persistResetHandoff {
7494+ i.persistCompactHandoff(resetHandoff)
7495+ }
71817496 i.invalidate()
71827497 i.runCompact(parent, compactContinuationRequest{origin: compactOriginPreTurnThreshold})
71837498 return
71847499 }
71857500 i.mu.Unlock()
7501+ if persistResetHandoff {
7502+ i.persistCompactHandoff(resetHandoff)
7503+ }
71867504
71877505 ctx, cancel := context.WithCancel(parent)
71887506 i.mu.Lock()
@@ -7349,18 +7667,23 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
73497667 continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil
73507668 shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
73517669 continueStatusRescue := false
7352- if statusRescueActive && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil {
7670+ var handoff json.RawMessage
7671+ var persistHandoff bool
7672+ if statusRescueActive && i.agent != nil && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil {
73537673 followUpMessages := i.agent.Messages()
73547674 reason := classifyCompactionContinuation(compactOriginManual, true, lastStop, lastTurnErr, followUpMessages)
73557675 if reason == compactContinuationStatusRescue && i.compactContinuation.rescueAttempts < maxStatusRescueContinuations {
7356- i.compactContinuation.rescueAttempts++
7676+ handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{
7677+ reason: compactContinuationStatusRescue,
7678+ rescueAttempts: i.compactContinuation.rescueAttempts + 1,
7679+ })
73577680 continueStatusRescue = true
73587681 } else {
7359- i.resetCompactContinuationLocked()
7682+ handoff, persistHandoff = i.resetCompactContinuationLocked()
73607683 }
73617684 }
7362- if !continueStatusRescue && (ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) {
7363- i.resetCompactContinuationLocked()
7685+ if !continueStatusRescue && (i.agent == nil || ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) {
7686+ handoff, persistHandoff = i.resetCompactContinuationLocked()
73647687 }
73657688 // The agent run can finish before the paced final text reaches the
73667689 // transcript. A compaction replaces that transcript, so it must never
@@ -7372,6 +7695,9 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
73727695 alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact)
73737696 i.busy = hasNext || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact
73747697 i.mu.Unlock()
7698+ if persistHandoff {
7699+ i.persistCompactHandoff(handoff)
7700+ }
73757701 if alertReason != "" {
73767702 i.scheduleMainAlert(alertReason)
73777703 }
@@ -7866,7 +8192,6 @@ func (i *Interactive) applyAgentPromptConfig(ag *core.Agent, system string, tool
78668192 // prepareReplacementAgentLocked applies session-wide capability policy before
78678193 // ag becomes visible to prompt submission. The caller holds agentMu and i.mu.
78688194 func (i *Interactive) prepareReplacementAgentLocked(ag *core.Agent) {
7869- i.resetCompactContinuationLocked()
78708195 if ag == nil {
78718196 i.setWebSearchAvailable(false)
78728197 return
@@ -8778,11 +9103,27 @@ func (i *Interactive) doSessionImport(src string) {
87789103 return
87799104 }
87809105 i.restoreLoadedSessionTitle()
9106+ state := i.restoreCurrentCompactHandoff()
87819107 i.mu.Lock()
87829108 i.statusOK = "imported and switched to session " + friendlyPath(newPath)
87839109 i.statusErr = ""
9110+ if i.agent != nil {
9111+ i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
9112+ i.cumUsage = i.agent.Cost()
9113+ last := i.agent.LastTurnUsage()
9114+ i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
9115+ if len(i.view.Messages) > initialResumeTailLimit {
9116+ i.view.TailLimit = initialResumeTailLimit
9117+ } else {
9118+ i.view.TailLimit = 0
9119+ }
9120+ i.view.InvalidateRenderCache()
9121+ }
87849122 i.mu.Unlock()
87859123 i.invalidate()
9124+ if state.reason != compactContinuationNone {
9125+ i.startRestoredCompactHandoff(i.runCtx)
9126+ }
87869127 }
87879128
87889129 // defaultExportDir returns ~/Downloads when it exists, or ~ as a
@@ -8997,6 +9338,7 @@ func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo in
89979338 i.setSessionTreeError("tree: checkout failed: " + err.Error())
89989339 return
89999340 }
9341+ state := i.restoreCurrentCompactHandoff()
90009342
90019343 // LoadSession is intentionally the old func(string) error callback: the
90029344 // CLI and embedders already own agent/session swapping. Refresh the view
@@ -9028,10 +9370,14 @@ func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo in
90289370 i.statusErr = ""
90299371 i.mu.Unlock()
90309372 i.scrollToBottom()
9031- // Selection only changes local/session state. In particular, do not call
9032- // Submit, startTurn, or queue a message here; the restored user draft is
9033- // submitted only by a later explicit Enter.
9373+ // Selection does not submit, start, or queue the restored user draft; only
9374+ // a later explicit Enter does that. An inherited compact handoff may resume
9375+ // its hidden continuation below when this branch has the complete effective
9376+ // transcript, without submitting the restored draft.
90349377 i.invalidate()
9378+ if state.reason != compactContinuationNone {
9379+ i.startRestoredCompactHandoff(i.runCtx)
9380+ }
90359381 }
90369382
90379383 // applyForkSelection branches the current session at msgIdx+1 (so
@@ -9079,12 +9425,16 @@ func (i *Interactive) applyForkSelection(msgIdx int) {
90799425 i.invalidate()
90809426 return
90819427 }
9428+ state := i.restoreCurrentCompactHandoff()
90829429 i.resetSessionTitleForFreshBranch()
90839430 i.mu.Lock()
90849431 i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)
90859432 i.statusErr = ""
90869433 i.mu.Unlock()
90879434 i.invalidate()
9435+ if state.reason != compactContinuationNone {
9436+ i.startRestoredCompactHandoff(i.runCtx)
9437+ }
90889438 }
90899439
90909440 // 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,8 +3153,10 @@ 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()
3093 if busyCancel {3162 if busyCancel {
@@ -3096,6 +3165,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
3096 // goroutine unblocks and the context cancellation can3165 // goroutine unblocks and the context cancellation can
3097 // actually take effect.3166 // actually take effect.
3098 i.confirmDialog.CancelAll("turn cancelled")3167 i.confirmDialog.CancelAll("turn cancelled")
3168+ if persistHandoff {
3169+ i.persistCompactHandoff(handoff)
3170+ }
3099 return false3171 return false
3100 }3172 }
3101 case tui.KeyCtrlD:3173 case tui.KeyCtrlD:
@@ -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,10 +4034,13 @@ 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()
3951 cancel()4039 cancel()
3952 i.confirmDialog.CancelAll("turn cancelled")4040 i.confirmDialog.CancelAll("turn cancelled")
4041+ if persistHandoff {
4042+ i.persistCompactHandoff(handoff)
4043+ }
3953 }4044 }
3954 }4045 }
3955 4046
@@ -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,9 +6411,12 @@ 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()
6311 cancel()6416 cancel()
6417+ if persistHandoff {
6418+ i.persistCompactHandoff(handoff)
6419+ }
6312 }6420 }
6313 // ConfirmToolCall waits on its response channel rather than the turn6421 // ConfirmToolCall waits on its response channel rather than the turn
6314 // context, so cancellation must also resolve every pending prompt.6422 // context, so cancellation must also resolve every pending prompt.
@@ -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,179 @@ 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
6940+// autoCompactContinuationPrompt is an interactive-only compact-handoff
6941+// instruction. Its completion criteria must stay separate from Ponytail so
6942+// toggling the optional coding-style addendum never changes recovery behavior.
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.`6943 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 6944
6945+type compactHandoffResume uint8
6946+
6947+const (
6948+ compactHandoffDiscard compactHandoffResume = iota
6949+ compactHandoffContinueExisting
6950+ compactHandoffAppendPrompt
6951+)
6952+
6953+// classifyCompactHandoffResume validates that the persisted checkpoint still
6954+// describes the effective transcript. A normal assistant reply or a newer
6955+// explicit user message supersedes the checkpoint; tool activity remains an
6956+// unfinished existing turn. No hidden prompt means the process stopped after
6957+// recording the handoff but before appending its context message.
6958+func classifyCompactHandoffResume(messages []provider.Message) compactHandoffResume {
6959+ lastHandoff := -1
6960+ for idx := len(messages) - 1; idx >= 0; idx-- {
6961+ message := messages[idx]
6962+ if message.Role == provider.RoleUser && message.Meta[autoCompactContinueMetaKey] == "true" {
6963+ lastHandoff = idx
6964+ break
6965+ }
6966+ }
6967+ if lastHandoff < 0 {
6968+ return compactHandoffAppendPrompt
6969+ }
6970+ for _, message := range messages[lastHandoff+1:] {
6971+ switch message.Role {
6972+ case provider.RoleTool:
6973+ continue
6974+ case provider.RoleAssistant:
6975+ hasToolCall := false
6976+ for _, content := range message.Content {
6977+ if _, ok := content.(provider.ToolCallBlock); ok {
6978+ hasToolCall = true
6979+ break
6980+ }
6981+ }
6982+ if !hasToolCall {
6983+ return compactHandoffDiscard
6984+ }
6985+ default:
6986+ return compactHandoffDiscard
6987+ }
6988+ }
6989+ return compactHandoffContinueExisting
6990+}
6991+
6992+// startRestoredCompactHandoff resumes a handoff checkpoint restored from the
6993+// active session. When its hidden context message was already persisted, the
6994+// agent continues that existing turn rather than appending it again.
6995+func (i *Interactive) startRestoredCompactHandoff(parent context.Context) {
6996+ if parent == nil {
6997+ parent = i.runCtx
6998+ }
6999+ if parent == nil {
7000+ parent = context.Background()
7001+ }
7002+ i.mu.Lock()
7003+ state := i.compactContinuation
7004+ ag := i.agent
7005+ busy := i.busy || i.compacting || i.autoCompacting || i.shellRunning
7006+ if state.reason == compactContinuationNone || ag == nil || busy {
7007+ i.mu.Unlock()
7008+ return
7009+ }
7010+
7011+ var next string
7012+ continueQueued := false
7013+ var resume compactHandoffResume
7014+ var handoff json.RawMessage
7015+ var persistHandoff bool
7016+ switch {
7017+ case state.reason != compactContinuationForcedLength && len(i.queued) > 0:
7018+ next, i.queued = i.queued[0], i.queued[1:]
7019+ handoff, persistHandoff = i.resetCompactContinuationLocked()
7020+ case state.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0:
7021+ continueQueued = true
7022+ handoff, persistHandoff = i.resetCompactContinuationLocked()
7023+ default:
7024+ resume = classifyCompactHandoffResume(ag.Messages())
7025+ if resume == compactHandoffDiscard {
7026+ handoff, persistHandoff = i.resetCompactContinuationLocked()
7027+ }
7028+ }
7029+ // Reserve the turn slot before persisting or dispatching so concurrent input
7030+ // can only queue behind the restored handoff, never start a competing turn.
7031+ starting := next != "" || continueQueued || resume == compactHandoffContinueExisting || resume == compactHandoffAppendPrompt
7032+ i.busy = starting
7033+ i.mu.Unlock()
7034+ if persistHandoff {
7035+ i.persistCompactHandoff(handoff)
7036+ }
7037+ if !starting {
7038+ i.invalidate()
7039+ return
7040+ }
7041+ switch {
7042+ case next != "":
7043+ i.startTurn(parent, next)
7044+ case continueQueued:
7045+ i.startTurnRequest(parent, "", nil, true, false)
7046+ case resume == compactHandoffContinueExisting:
7047+ i.startTurnRequest(parent, "", nil, true, false)
7048+ case resume == compactHandoffAppendPrompt:
7049+ i.startAutoCompactContinuation(parent)
7050+ }
7051+}
7052+
6759 // startAutoCompactContinuation adds an internal user turn that tells the7053 // startAutoCompactContinuation adds an internal user turn that tells the
6760 // model to resume the task after threshold compaction. Provider requests7054 // model to resume the task after threshold compaction. Provider requests
6761 // cannot continue from an assistant tail on every supported API, so the7055 // cannot continue from an assistant tail on every supported API, so the
@@ -6779,14 +7073,20 @@ func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
6779 if i.compactContinuation.reason != compactContinuationForcedLength && len(i.queued) > 0 {7073 if i.compactContinuation.reason != compactContinuationForcedLength && len(i.queued) > 0 {
6780 next := i.queued[0]7074 next := i.queued[0]
6781 i.queued = i.queued[1:]7075 i.queued = i.queued[1:]
6782- i.resetCompactContinuationLocked()7076+ handoff, persistHandoff := i.resetCompactContinuationLocked()
6783 i.mu.Unlock()7077 i.mu.Unlock()
7078+ if persistHandoff {
7079+ i.persistCompactHandoff(handoff)
7080+ }
6784 i.startTurn(parent, next)7081 i.startTurn(parent, next)
6785 return7082 return
6786 }7083 }
6787 if i.compactContinuation.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0 {7084 if i.compactContinuation.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0 {
6788- i.resetCompactContinuationLocked()7085+ handoff, persistHandoff := i.resetCompactContinuationLocked()
6789 i.mu.Unlock()7086 i.mu.Unlock()
7087+ if persistHandoff {
7088+ i.persistCompactHandoff(handoff)
7089+ }
6790 i.startTurnRequest(parent, "", nil, true, false)7090 i.startTurnRequest(parent, "", nil, true, false)
6791 return7091 return
6792 }7092 }
@@ -6827,8 +7127,10 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
6827 i.activity.kind = activityCompactingHistory7127 i.activity.kind = activityCompactingHistory
6828 }7128 }
6829 i.cancelTurn = cancel7129 i.cancelTurn = cancel
7130+ var initialHandoff json.RawMessage
7131+ persistInitialHandoff := false
6830 if request.origin == compactOriginManual || request.origin == compactOriginRecovery {7132 if request.origin == compactOriginManual || request.origin == compactOriginRecovery {
6831- i.resetCompactContinuationLocked()7133+ initialHandoff, persistInitialHandoff = i.resetCompactContinuationLocked()
6832 }7134 }
6833 i.statusErr = ""7135 i.statusErr = ""
6834 i.statusOK = ""7136 i.statusOK = ""
@@ -6838,6 +7140,9 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
6838 i.scrollOffset = 07140 i.scrollOffset = 0
6839 i.helpBlock = nil7141 i.helpBlock = nil
6840 i.mu.Unlock()7142 i.mu.Unlock()
7143+ if persistInitialHandoff {
7144+ i.persistCompactHandoff(initialHandoff)
7145+ }
6841 i.invalidate()7146 i.invalidate()
6842 7147
6843 go func() {7148 go func() {
@@ -6880,6 +7185,8 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
6880 var hasNext bool7185 var hasNext bool
6881 var continueExisting bool7186 var continueExisting bool
6882 var continueAutomatically bool7187 var continueAutomatically bool
7188+ var handoff json.RawMessage
7189+ var persistHandoff bool
6883 7190
6884 switch {7191 switch {
6885 case err != nil && ctx.Err() != nil:7192 case err != nil && ctx.Err() != nil:
@@ -6891,7 +7198,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
6891 }7198 }
6892 i.queued = nil // drop queue on cancel7199 i.queued = nil // drop queue on cancel
6893 i.clearPendingCompactTurnLocked()7200 i.clearPendingCompactTurnLocked()
6894- i.resetCompactContinuationLocked()7201+ handoff, persistHandoff = i.resetCompactContinuationLocked()
6895 if i.agent != nil {7202 if i.agent != nil {
6896 i.agent.DrainQueuedMessages()7203 i.agent.DrainQueuedMessages()
6897 }7204 }
@@ -6900,7 +7207,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
6900 i.statusOK = ""7207 i.statusOK = ""
6901 i.queued = nil // drop queue on error7208 i.queued = nil // drop queue on error
6902 i.clearPendingCompactTurnLocked()7209 i.clearPendingCompactTurnLocked()
6903- i.resetCompactContinuationLocked()7210+ handoff, persistHandoff = i.resetCompactContinuationLocked()
6904 if i.agent != nil {7211 if i.agent != nil {
6905 i.agent.DrainQueuedMessages()7212 i.agent.DrainQueuedMessages()
6906 }7213 }
@@ -6941,12 +7248,12 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
6941 case continuationReason == compactContinuationForcedLength:7248 case continuationReason == compactContinuationForcedLength:
6942 // Forced truncated-output continuation keeps its existing7249 // Forced truncated-output continuation keeps its existing
6943 // priority over an explicitly queued prompt.7250 // priority over an explicitly queued prompt.
6944- i.compactContinuation = compactContinuationState{reason: continuationReason}7251+ handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
6945 continueAutomatically = true7252 continueAutomatically = true
6946 case len(i.queued) > 0:7253 case len(i.queued) > 0:
6947 next, i.queued = i.queued[0], i.queued[1:]7254 next, i.queued = i.queued[0], i.queued[1:]
6948 hasNext = true7255 hasNext = true
6949- i.resetCompactContinuationLocked()7256+ handoff, persistHandoff = i.resetCompactContinuationLocked()
6950 case continuationReason == compactContinuationStructuralTail || continuationReason == compactContinuationStatusRescue:7257 case continuationReason == compactContinuationStructuralTail || continuationReason == compactContinuationStatusRescue:
6951 if continuationReason == compactContinuationStatusRescue {7258 if continuationReason == compactContinuationStatusRescue {
6952 attempts := 17259 attempts := 1
@@ -6954,19 +7261,19 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
6954 attempts = i.compactContinuation.rescueAttempts + 17261 attempts = i.compactContinuation.rescueAttempts + 1
6955 }7262 }
6956 if attempts > maxStatusRescueContinuations {7263 if attempts > maxStatusRescueContinuations {
6957- i.resetCompactContinuationLocked()7264+ handoff, persistHandoff = i.resetCompactContinuationLocked()
6958 break7265 break
6959 }7266 }
6960- i.compactContinuation = compactContinuationState{7267+ handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{
6961 reason: continuationReason,7268 reason: continuationReason,
6962 rescueAttempts: attempts,7269 rescueAttempts: attempts,
6963- }7270+ })
6964 } else {7271 } else {
6965- i.compactContinuation = compactContinuationState{reason: continuationReason}7272+ handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
6966 }7273 }
6967 continueAutomatically = true7274 continueAutomatically = true
6968 default:7275 default:
6969- i.resetCompactContinuationLocked()7276+ handoff, persistHandoff = i.resetCompactContinuationLocked()
6970 }7277 }
6971 }7278 }
6972 // Keep the host busy until the hand-off decision is committed under7279 // Keep the host busy until the hand-off decision is committed under
@@ -6977,6 +7284,9 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
6977 i.compacting = false7284 i.compacting = false
6978 i.autoCompacting = false7285 i.autoCompacting = false
6979 i.mu.Unlock()7286 i.mu.Unlock()
7287+ if persistHandoff {
7288+ i.persistCompactHandoff(handoff)
7289+ }
6980 runPendingIdleWork(pendingIdleWork)7290 runPendingIdleWork(pendingIdleWork)
6981 i.invalidate()7291 i.invalidate()
6982 7292
@@ -7156,8 +7466,10 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
7156 // The condense flow re-fires the user's queued prompt for us, so7466 // The condense flow re-fires the user's queued prompt for us, so
7157 // we just hand it off and exit.7467 // we just hand it off and exit.
7158 i.mu.Lock()7468 i.mu.Lock()
7469+ var resetHandoff json.RawMessage
7470+ var persistResetHandoff bool
7159 if !continueExisting {7471 if !continueExisting {
7160- i.resetCompactContinuationLocked()7472+ resetHandoff, persistResetHandoff = i.resetCompactContinuationLocked()
7161 }7473 }
7162 needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()7474 needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()
7163 if needsPreCompact {7475 if needsPreCompact {
@@ -7178,11 +7490,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..."))7490 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"7491 i.pendingPostCompactNote = "context auto-compacted; sending your last message"
7180 i.mu.Unlock()7492 i.mu.Unlock()
7493+ if persistResetHandoff {
7494+ i.persistCompactHandoff(resetHandoff)
7495+ }
7181 i.invalidate()7496 i.invalidate()
7182 i.runCompact(parent, compactContinuationRequest{origin: compactOriginPreTurnThreshold})7497 i.runCompact(parent, compactContinuationRequest{origin: compactOriginPreTurnThreshold})
7183 return7498 return
7184 }7499 }
7185 i.mu.Unlock()7500 i.mu.Unlock()
7501+ if persistResetHandoff {
7502+ i.persistCompactHandoff(resetHandoff)
7503+ }
7186 7504
7187 ctx, cancel := context.WithCancel(parent)7505 ctx, cancel := context.WithCancel(parent)
7188 i.mu.Lock()7506 i.mu.Lock()
@@ -7349,18 +7667,23 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
7349 continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil7667 continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil
7350 shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()7668 shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
7351 continueStatusRescue := false7669 continueStatusRescue := false
7352- if statusRescueActive && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil {7670+ var handoff json.RawMessage
7671+ var persistHandoff bool
7672+ if statusRescueActive && i.agent != nil && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil {
7353 followUpMessages := i.agent.Messages()7673 followUpMessages := i.agent.Messages()
7354 reason := classifyCompactionContinuation(compactOriginManual, true, lastStop, lastTurnErr, followUpMessages)7674 reason := classifyCompactionContinuation(compactOriginManual, true, lastStop, lastTurnErr, followUpMessages)
7355 if reason == compactContinuationStatusRescue && i.compactContinuation.rescueAttempts < maxStatusRescueContinuations {7675 if reason == compactContinuationStatusRescue && i.compactContinuation.rescueAttempts < maxStatusRescueContinuations {
7356- i.compactContinuation.rescueAttempts++7676+ handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{
7677+ reason: compactContinuationStatusRescue,
7678+ rescueAttempts: i.compactContinuation.rescueAttempts + 1,
7679+ })
7357 continueStatusRescue = true7680 continueStatusRescue = true
7358 } else {7681 } else {
7359- i.resetCompactContinuationLocked()7682+ handoff, persistHandoff = i.resetCompactContinuationLocked()
7360 }7683 }
7361 }7684 }
7362- if !continueStatusRescue && (ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) {7685+ if !continueStatusRescue && (i.agent == nil || ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) {
7363- i.resetCompactContinuationLocked()7686+ handoff, persistHandoff = i.resetCompactContinuationLocked()
7364 }7687 }
7365 // The agent run can finish before the paced final text reaches the7688 // The agent run can finish before the paced final text reaches the
7366 // transcript. A compaction replaces that transcript, so it must never7689 // transcript. A compaction replaces that transcript, so it must never
@@ -7372,6 +7695,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)7695 alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact)
7373 i.busy = hasNext || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact7696 i.busy = hasNext || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact
7374 i.mu.Unlock()7697 i.mu.Unlock()
7698+ if persistHandoff {
7699+ i.persistCompactHandoff(handoff)
7700+ }
7375 if alertReason != "" {7701 if alertReason != "" {
7376 i.scheduleMainAlert(alertReason)7702 i.scheduleMainAlert(alertReason)
7377 }7703 }
@@ -7866,7 +8192,6 @@ func (i *Interactive) applyAgentPromptConfig(ag *core.Agent, system string, tool
7866 // prepareReplacementAgentLocked applies session-wide capability policy before8192 // prepareReplacementAgentLocked applies session-wide capability policy before
7867 // ag becomes visible to prompt submission. The caller holds agentMu and i.mu.8193 // ag becomes visible to prompt submission. The caller holds agentMu and i.mu.
7868 func (i *Interactive) prepareReplacementAgentLocked(ag *core.Agent) {8194 func (i *Interactive) prepareReplacementAgentLocked(ag *core.Agent) {
7869- i.resetCompactContinuationLocked()
7870 if ag == nil {8195 if ag == nil {
7871 i.setWebSearchAvailable(false)8196 i.setWebSearchAvailable(false)
7872 return8197 return
@@ -8778,11 +9103,27 @@ func (i *Interactive) doSessionImport(src string) {
8778 return9103 return
8779 }9104 }
8780 i.restoreLoadedSessionTitle()9105 i.restoreLoadedSessionTitle()
9106+ state := i.restoreCurrentCompactHandoff()
8781 i.mu.Lock()9107 i.mu.Lock()
8782 i.statusOK = "imported and switched to session " + friendlyPath(newPath)9108 i.statusOK = "imported and switched to session " + friendlyPath(newPath)
8783 i.statusErr = ""9109 i.statusErr = ""
9110+ if i.agent != nil {
9111+ i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
9112+ i.cumUsage = i.agent.Cost()
9113+ last := i.agent.LastTurnUsage()
9114+ i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
9115+ if len(i.view.Messages) > initialResumeTailLimit {
9116+ i.view.TailLimit = initialResumeTailLimit
9117+ } else {
9118+ i.view.TailLimit = 0
9119+ }
9120+ i.view.InvalidateRenderCache()
9121+ }
8784 i.mu.Unlock()9122 i.mu.Unlock()
8785 i.invalidate()9123 i.invalidate()
9124+ if state.reason != compactContinuationNone {
9125+ i.startRestoredCompactHandoff(i.runCtx)
9126+ }
8786 }9127 }
8787 9128
8788 // defaultExportDir returns ~/Downloads when it exists, or ~ as a9129 // defaultExportDir returns ~/Downloads when it exists, or ~ as a
@@ -8997,6 +9338,7 @@ func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo in
8997 i.setSessionTreeError("tree: checkout failed: " + err.Error())9338 i.setSessionTreeError("tree: checkout failed: " + err.Error())
8998 return9339 return
8999 }9340 }
9341+ state := i.restoreCurrentCompactHandoff()
9000 9342
9001 // LoadSession is intentionally the old func(string) error callback: the9343 // LoadSession is intentionally the old func(string) error callback: the
9002 // CLI and embedders already own agent/session swapping. Refresh the view9344 // CLI and embedders already own agent/session swapping. Refresh the view
@@ -9028,10 +9370,14 @@ func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo in
9028 i.statusErr = ""9370 i.statusErr = ""
9029 i.mu.Unlock()9371 i.mu.Unlock()
9030 i.scrollToBottom()9372 i.scrollToBottom()
9031- // Selection only changes local/session state. In particular, do not call9373+ // Selection does not submit, start, or queue the restored user draft; only
9032- // Submit, startTurn, or queue a message here; the restored user draft is9374+ // a later explicit Enter does that. An inherited compact handoff may resume
9033- // submitted only by a later explicit Enter.9375+ // its hidden continuation below when this branch has the complete effective
9376+ // transcript, without submitting the restored draft.
9034 i.invalidate()9377 i.invalidate()
9378+ if state.reason != compactContinuationNone {
9379+ i.startRestoredCompactHandoff(i.runCtx)
9380+ }
9035 }9381 }
9036 9382
9037 // applyForkSelection branches the current session at msgIdx+1 (so9383 // applyForkSelection branches the current session at msgIdx+1 (so
@@ -9079,12 +9425,16 @@ func (i *Interactive) applyForkSelection(msgIdx int) {
9079 i.invalidate()9425 i.invalidate()
9080 return9426 return
9081 }9427 }
9428+ state := i.restoreCurrentCompactHandoff()
9082 i.resetSessionTitleForFreshBranch()9429 i.resetSessionTitleForFreshBranch()
9083 i.mu.Lock()9430 i.mu.Lock()
9084 i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)9431 i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)
9085 i.statusErr = ""9432 i.statusErr = ""
9086 i.mu.Unlock()9433 i.mu.Unlock()
9087 i.invalidate()9434 i.invalidate()
9435+ if state.reason != compactContinuationNone {
9436+ i.startRestoredCompactHandoff(i.runCtx)
9437+ }
9088 }9438 }
9089 9439
9090 // formatInt is a tiny strconv.Itoa shim; keeps the handler above9440 // formatInt is a tiny strconv.Itoa shim; keeps the handler above
M packages/agent/ponytail.md +0 -5
@@ -11,9 +11,4 @@ When coding, debugging, reviewing, or changing software:
1111 - Avoid speculative features, boilerplate, and unrelated refactors.
1212 - Before finishing non-trivial work, run the narrowest useful check and report what remains uncertain.
1313
14-For an active multi-step implementation task, a progress report, plan update,
15-or statement of future work is not a completed response. Continue with the
16-next concrete action now. End only after required work and validation are
17-complete, or when blocked on a specific decision that requires the user.
18-
1914 This is coding guidance, not a permission or security policy. Apply it to engineering work, not ordinary conversation or creative requests.
@@ -11,9 +11,4 @@ When coding, debugging, reviewing, or changing software:
11 - Avoid speculative features, boilerplate, and unrelated refactors.11 - Avoid speculative features, boilerplate, and unrelated refactors.
12 - Before finishing non-trivial work, run the narrowest useful check and report what remains uncertain.12 - Before finishing non-trivial work, run the narrowest useful check and report what remains uncertain.
13 13
14-For an active multi-step implementation task, a progress report, plan update,
15-or statement of future work is not a completed response. Continue with the
16-next concrete action now. End only after required work and validation are
17-complete, or when blocked on a specific decision that requires the user.
18-
19 This is coding guidance, not a permission or security policy. Apply it to engineering work, not ordinary conversation or creative requests.14 This is coding guidance, not a permission or security policy. Apply it to engineering work, not ordinary conversation or creative requests.
M packages/agent/ponytail_test.go +1 -1
@@ -34,7 +34,7 @@ func TestPonytailSystemAddendumIsCompact(t *testing.T) {
3434 if len(addendum) > 2000 {
3535 t.Fatalf("Ponytail system addendum is %d bytes, want at most 2000", len(addendum))
3636 }
37- for _, want := range []string{"YAGNI", "standard library", "validation", "ordinary conversation", "active multi-step implementation task", "required work and validation", "specific decision that requires the user"} {
37+ for _, want := range []string{"YAGNI", "standard library", "validation", "ordinary conversation"} {
3838 if !strings.Contains(addendum, want) {
3939 t.Fatalf("Ponytail system addendum missing %q:\n%s", want, addendum)
4040 }
@@ -34,7 +34,7 @@ func TestPonytailSystemAddendumIsCompact(t *testing.T) {
34 if len(addendum) > 2000 {34 if len(addendum) > 2000 {
35 t.Fatalf("Ponytail system addendum is %d bytes, want at most 2000", len(addendum))35 t.Fatalf("Ponytail system addendum is %d bytes, want at most 2000", len(addendum))
36 }36 }
37- for _, want := range []string{"YAGNI", "standard library", "validation", "ordinary conversation", "active multi-step implementation task", "required work and validation", "specific decision that requires the user"} {37+ for _, want := range []string{"YAGNI", "standard library", "validation", "ordinary conversation"} {
38 if !strings.Contains(addendum, want) {38 if !strings.Contains(addendum, want) {
39 t.Fatalf("Ponytail system addendum missing %q:\n%s", want, addendum)39 t.Fatalf("Ponytail system addendum missing %q:\n%s", want, addendum)
40 }40 }
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