feat(session): resume compact handoffs
- commit
- 6d13ad6e438ae2c4ac5484723ecd074fdcf11766
- author
- brice · · 2026-08-07 06:39 UTC
- committer
- brice · · 2026-08-07 07:48 UTC
- parent
- 01ec125
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
10 files · +932 -97
M README.md +2 -2
| @@ -407,7 +407,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||
| 407 | 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 | 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 | 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 | 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 | 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 | 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 | 425 | Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Manual `/compact` only summarizes; it does not silently start work afterward. Use it when the context meter creeps past ~80%. | |
| 426 | 426 | ||
| 427 | - | zut also auto-compacts in the background after an interactive turn reaches the configured context threshold. Structural unfinished tails and output stopped at the model's token limit continue as before. After a successful after-turn threshold compaction, a visible assistant status that appears to promise future work may receive at most two hidden, bounded recovery continuations; clear completed text settles normally, and a newer queued user request supersedes that recovery. Manual `/compact` does not silently start work, and truncated-output continuation remains a separate priority rule. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a context-window or payload-too-large response. | |
| 427 | + | zut also auto-compacts in the background after an interactive turn reaches the configured context threshold. Structural unfinished tails and output stopped at the model's token limit continue as before. After a successful after-turn threshold compaction, a visible assistant status that appears to promise future work may receive at most two hidden, bounded recovery continuations; clear completed text settles normally, and a newer queued user request supersedes that recovery. An interrupted interactive session resumes an outstanding hidden handoff without displaying it as a chat message. Manual `/compact` does not silently start work, and truncated-output continuation remains a separate priority rule. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a context-window or payload-too-large response. | |
| 428 | 428 | ||
| 429 | 429 | ### `/jail` | |
| 430 | 430 | ||
| @@ -407,7 +407,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||||
| 407 | - **fast mode** — request the provider's fast tier where supported. It currently uses OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; toggle it with `/fast` or `/settings`; changes apply on the next model call and persist as `fast_mode`. Other providers currently return an unsupported-provider error. Fast mode may cost more and depends on the selected model/account. | 407 | - **fast mode** — request the provider's fast tier where supported. It currently uses OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; toggle it with `/fast` or `/settings`; changes apply on the next model call and persist as `fast_mode`. Other providers currently return an unsupported-provider error. Fast mode may cost more and depends on the selected model/account. | ||
| 408 | - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`. | 408 | - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`. | ||
| 409 | - **lsp in sub-agents** — allow newly spawned background sub-agents to use the built-in `lsp` tool. Enabled by default; changes persist as `subagent_lsp_enabled` and apply when a child starts. | 409 | - **lsp in sub-agents** — allow newly spawned background sub-agents to use the built-in `lsp` tool. Enabled by default; changes persist as `subagent_lsp_enabled` and apply when a child starts. | ||
| 410 | - | - **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. Structural unfinished tails and truncated output continue as before. After a successful after-turn threshold compaction, zut may make at most two hidden, bounded recovery continuations when the assistant's visible text appears to promise future work; clear completed text settles normally, and a newer queued user request wins. Manual `/compact` does not silently start work. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. Truncated-output continuation remains a separate priority rule. | 410 | + | - **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. Structural unfinished tails and truncated output continue as before. After a successful after-turn threshold compaction, zut may make at most two hidden, bounded recovery continuations when the assistant's visible text appears to promise future work; clear completed text settles normally, and a newer queued user request wins. An interrupted interactive session retains an outstanding handoff when resumed, including after portable export and import. Manual `/compact` does not silently start work. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. Truncated-output continuation remains a separate priority rule. |
| 411 | - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default. | 411 | - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default. | ||
| 412 | - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`. | 412 | - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`. | ||
| 413 | - **show loaded resources at startup**: list the active Zutfile agent (for `zut run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`. | 413 | - **show loaded resources at startup**: list the active Zutfile agent (for `zut run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`. | ||
| @@ -424,7 +424,7 @@ Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each ro | |||||
| 424 | 424 | ||||
| 425 | Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Manual `/compact` only summarizes; it does not silently start work afterward. Use it when the context meter creeps past ~80%. | 425 | Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Manual `/compact` only summarizes; it does not silently start work afterward. Use it when the context meter creeps past ~80%. | ||
| 426 | 426 | ||||
| 427 | - | zut also auto-compacts in the background after an interactive turn reaches the configured context threshold. Structural unfinished tails and output stopped at the model's token limit continue as before. After a successful after-turn threshold compaction, a visible assistant status that appears to promise future work may receive at most two hidden, bounded recovery continuations; clear completed text settles normally, and a newer queued user request supersedes that recovery. Manual `/compact` does not silently start work, and truncated-output continuation remains a separate priority rule. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a context-window or payload-too-large response. | 427 | + | zut also auto-compacts in the background after an interactive turn reaches the configured context threshold. Structural unfinished tails and output stopped at the model's token limit continue as before. After a successful after-turn threshold compaction, a visible assistant status that appears to promise future work may receive at most two hidden, bounded recovery continuations; clear completed text settles normally, and a newer queued user request supersedes that recovery. An interrupted interactive session resumes an outstanding hidden handoff without displaying it as a chat message. Manual `/compact` does not silently start work, and truncated-output continuation remains a separate priority rule. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a context-window or payload-too-large response. |
| 428 | 428 | ||||
| 429 | ### `/jail` | 429 | ### `/jail` | ||
| 430 | 430 | ||||
M packages/agent/cli.go +24 -3
| @@ -1748,6 +1748,24 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1748 | 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 | 1769 | persistToolResult := func(_ string, result core.ToolResult) { | |
| 1752 | 1770 | persistMu.Lock() | |
| 1753 | 1771 | defer persistMu.Unlock() | |
| @@ -1870,7 +1888,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1870 | 1888 | // writer, so no new message can land on the old file between the | |
| 1871 | 1889 | // two state changes. | |
| 1872 | 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 | 1893 | ag = candidate.agent | |
| 1876 | 1894 | } else { | |
| @@ -2242,6 +2260,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 2242 | 2260 | UpdateInfoChan: updateCh, | |
| 2243 | 2261 | Sandbox: sharedSandbox, | |
| 2244 | 2262 | Agent: ag, | |
| 2263 | + | InitialCompactHandoff: currentCompactHandoff(), | |
| 2245 | 2264 | InitialSessionTitle: func() string { | |
| 2246 | 2265 | if sess == nil || sessionTitlePending { | |
| 2247 | 2266 | return "" | |
| @@ -2315,8 +2334,10 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 2315 | 2334 | } | |
| 2316 | 2335 | return out | |
| 2317 | 2336 | }, | |
| 2318 | - | LoadSession: loadSession, | |
| 2319 | - | ChangeCWD: changeCWD, | |
| 2337 | + | LoadSession: loadSession, | |
| 2338 | + | ChangeCWD: changeCWD, | |
| 2339 | + | PersistCompactHandoff: persistCompactHandoff, | |
| 2340 | + | CurrentCompactHandoff: currentCompactHandoff, | |
| 2320 | 2341 | CurrentSessionPath: func() string { | |
| 2321 | 2342 | persistMu.Lock() | |
| 2322 | 2343 | 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 the | 1888 | // 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.agent | 1893 | 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 out | 2335 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "encoding/json" | |
| 5 | 6 | "errors" | |
| 6 | 7 | "fmt" | |
| 7 | 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 | 283 | func TestPrepareSessionResumeFailureLeavesCurrentAndCandidateFileUsable(t *testing.T) { | |
| 258 | 284 | old := core.NewAgent(nil, "old-model", "", nil) | |
| 259 | 285 | old.SetMessages([]provider.Message{{ | |
| @@ -2,6 +2,7 @@ package agent | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "encoding/json" | |||
| 5 | "errors" | 6 | "errors" | ||
| 6 | "fmt" | 7 | "fmt" | ||
| 7 | "os" | 8 | "os" | ||
| @@ -254,6 +255,31 @@ func TestPrepareSessionResumePreservesLegacyMissingMetadata(t *testing.T) { | |||||
| 254 | } | 255 | } | ||
| 255 | } | 256 | } | ||
| 256 | 257 | ||||
| 258 | + | func TestPrepareSessionResumePreservesCompactHandoffMetadata(t *testing.T) { | |||
| 259 | + | path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{}) | |||
| 260 | + | session, _, err := core.OpenSession(path) | |||
| 261 | + | if err != nil { | |||
| 262 | + | t.Fatal(err) | |||
| 263 | + | } | |||
| 264 | + | handoff := json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`) | |||
| 265 | + | if err := session.UpdateCompactHandoff(handoff); err != nil { | |||
| 266 | + | t.Fatal(err) | |||
| 267 | + | } | |||
| 268 | + | if err := session.Close(); err != nil { | |||
| 269 | + | t.Fatal(err) | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | current := core.NewAgent(nil, "current-model", "", nil) | |||
| 273 | + | candidate, err := prepareSessionResume(path, current, "stored-provider", "stored-model", nil) | |||
| 274 | + | if err != nil { | |||
| 275 | + | t.Fatal(err) | |||
| 276 | + | } | |||
| 277 | + | defer candidate.session.Close() | |||
| 278 | + | if got := string(candidate.session.Meta.CompactHandoff); got != string(handoff) { | |||
| 279 | + | t.Fatalf("candidate compact handoff = %q, want %q", got, handoff) | |||
| 280 | + | } | |||
| 281 | + | } | |||
| 282 | + | ||||
| 257 | func TestPrepareSessionResumeFailureLeavesCurrentAndCandidateFileUsable(t *testing.T) { | 283 | func TestPrepareSessionResumeFailureLeavesCurrentAndCandidateFileUsable(t *testing.T) { | ||
| 258 | old := core.NewAgent(nil, "old-model", "", nil) | 284 | old := core.NewAgent(nil, "old-model", "", nil) | ||
| 259 | old.SetMessages([]provider.Message{{ | 285 | old.SetMessages([]provider.Message{{ | ||
M packages/agent/modes/auto_compact_test.go +69 -0
| @@ -1,8 +1,11 @@ | |||
| 1 | 1 | package modes | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 4 | 6 | "errors" | |
| 5 | 7 | "testing" | |
| 8 | + | "time" | |
| 6 | 9 | ||
| 7 | 10 | "github.com/bnema/zut/packages/provider" | |
| 8 | 11 | ) | |
| @@ -233,6 +236,72 @@ func TestAutoCompactContinuationIsHiddenFromTranscriptButPersisted(t *testing.T) | |||
| 233 | 236 | } | |
| 234 | 237 | } | |
| 235 | 238 | ||
| 239 | + | func TestNewInteractiveRestoresValidCompactHandoff(t *testing.T) { | |
| 240 | + | interactive := NewInteractive(InteractiveConfig{ | |
| 241 | + | InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`), | |
| 242 | + | }) | |
| 243 | + | if got, want := interactive.compactContinuation, (compactContinuationState{reason: compactContinuationStatusRescue, rescueAttempts: 1}); got != want { | |
| 244 | + | t.Fatalf("restored compact handoff = %#v, want %#v", got, want) | |
| 245 | + | } | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func TestCancelTurnClearsPersistedCompactHandoff(t *testing.T) { | |
| 249 | + | persisted := make(chan string, 1) | |
| 250 | + | interactive := NewInteractive(InteractiveConfig{ | |
| 251 | + | InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`), | |
| 252 | + | PersistCompactHandoff: func(state json.RawMessage) error { | |
| 253 | + | persisted <- string(state) | |
| 254 | + | return nil | |
| 255 | + | }, | |
| 256 | + | }) | |
| 257 | + | _, cancel := context.WithCancel(context.Background()) | |
| 258 | + | interactive.mu.Lock() | |
| 259 | + | interactive.busy = true | |
| 260 | + | interactive.cancelTurn = cancel | |
| 261 | + | interactive.mu.Unlock() | |
| 262 | + | ||
| 263 | + | interactive.CancelTurn() | |
| 264 | + | select { | |
| 265 | + | case state := <-persisted: | |
| 266 | + | if state != "" { | |
| 267 | + | t.Fatalf("cleared compact handoff = %q, want empty", state) | |
| 268 | + | } | |
| 269 | + | case <-time.After(2 * time.Second): | |
| 270 | + | t.Fatal("cancellation did not clear the persisted compact handoff") | |
| 271 | + | } | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | func TestDecodeCompactHandoff(t *testing.T) { | |
| 275 | + | tests := []struct { | |
| 276 | + | name string | |
| 277 | + | raw string | |
| 278 | + | want compactContinuationState | |
| 279 | + | }{ | |
| 280 | + | { | |
| 281 | + | name: "status rescue", | |
| 282 | + | raw: `{"version":1,"reason":"status_rescue","rescue_attempts":1}`, | |
| 283 | + | want: compactContinuationState{reason: compactContinuationStatusRescue, rescueAttempts: 1}, | |
| 284 | + | }, | |
| 285 | + | { | |
| 286 | + | name: "forced length", | |
| 287 | + | raw: `{"version":1,"reason":"forced_length"}`, | |
| 288 | + | want: compactContinuationState{reason: compactContinuationForcedLength}, | |
| 289 | + | }, | |
| 290 | + | { | |
| 291 | + | name: "invalid status attempt", raw: `{"version":1,"reason":"status_rescue","rescue_attempts":3}`}, | |
| 292 | + | {name: "unknown reason", raw: `{"version":1,"reason":"other"}`}, | |
| 293 | + | {name: "invalid JSON", raw: `{`}, | |
| 294 | + | {name: "missing", raw: ""}, | |
| 295 | + | } | |
| 296 | + | for _, tt := range tests { | |
| 297 | + | t.Run(tt.name, func(t *testing.T) { | |
| 298 | + | if got := decodeCompactHandoff([]byte(tt.raw)); got != tt.want { | |
| 299 | + | t.Fatalf("decodeCompactHandoff(%q) = %#v, want %#v", tt.raw, got, tt.want) | |
| 300 | + | } | |
| 301 | + | }) | |
| 302 | + | } | |
| 303 | + | } | |
| 304 | + | ||
| 236 | 305 | func TestShouldAutoCompactUsesConfiguredThreshold(t *testing.T) { | |
| 237 | 306 | tests := []struct { | |
| 238 | 307 | name string | |
| @@ -1,8 +1,11 @@ | |||||
| 1 | package modes | 1 | package modes | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 4 | "errors" | 6 | "errors" | ||
| 5 | "testing" | 7 | "testing" | ||
| 8 | + | "time" | |||
| 6 | 9 | ||||
| 7 | "github.com/bnema/zut/packages/provider" | 10 | "github.com/bnema/zut/packages/provider" | ||
| 8 | ) | 11 | ) | ||
| @@ -233,6 +236,72 @@ func TestAutoCompactContinuationIsHiddenFromTranscriptButPersisted(t *testing.T) | |||||
| 233 | } | 236 | } | ||
| 234 | } | 237 | } | ||
| 235 | 238 | ||||
| 239 | + | func TestNewInteractiveRestoresValidCompactHandoff(t *testing.T) { | |||
| 240 | + | interactive := NewInteractive(InteractiveConfig{ | |||
| 241 | + | InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`), | |||
| 242 | + | }) | |||
| 243 | + | if got, want := interactive.compactContinuation, (compactContinuationState{reason: compactContinuationStatusRescue, rescueAttempts: 1}); got != want { | |||
| 244 | + | t.Fatalf("restored compact handoff = %#v, want %#v", got, want) | |||
| 245 | + | } | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | func TestCancelTurnClearsPersistedCompactHandoff(t *testing.T) { | |||
| 249 | + | persisted := make(chan string, 1) | |||
| 250 | + | interactive := NewInteractive(InteractiveConfig{ | |||
| 251 | + | InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`), | |||
| 252 | + | PersistCompactHandoff: func(state json.RawMessage) error { | |||
| 253 | + | persisted <- string(state) | |||
| 254 | + | return nil | |||
| 255 | + | }, | |||
| 256 | + | }) | |||
| 257 | + | _, cancel := context.WithCancel(context.Background()) | |||
| 258 | + | interactive.mu.Lock() | |||
| 259 | + | interactive.busy = true | |||
| 260 | + | interactive.cancelTurn = cancel | |||
| 261 | + | interactive.mu.Unlock() | |||
| 262 | + | ||||
| 263 | + | interactive.CancelTurn() | |||
| 264 | + | select { | |||
| 265 | + | case state := <-persisted: | |||
| 266 | + | if state != "" { | |||
| 267 | + | t.Fatalf("cleared compact handoff = %q, want empty", state) | |||
| 268 | + | } | |||
| 269 | + | case <-time.After(2 * time.Second): | |||
| 270 | + | t.Fatal("cancellation did not clear the persisted compact handoff") | |||
| 271 | + | } | |||
| 272 | + | } | |||
| 273 | + | ||||
| 274 | + | func TestDecodeCompactHandoff(t *testing.T) { | |||
| 275 | + | tests := []struct { | |||
| 276 | + | name string | |||
| 277 | + | raw string | |||
| 278 | + | want compactContinuationState | |||
| 279 | + | }{ | |||
| 280 | + | { | |||
| 281 | + | name: "status rescue", | |||
| 282 | + | raw: `{"version":1,"reason":"status_rescue","rescue_attempts":1}`, | |||
| 283 | + | want: compactContinuationState{reason: compactContinuationStatusRescue, rescueAttempts: 1}, | |||
| 284 | + | }, | |||
| 285 | + | { | |||
| 286 | + | name: "forced length", | |||
| 287 | + | raw: `{"version":1,"reason":"forced_length"}`, | |||
| 288 | + | want: compactContinuationState{reason: compactContinuationForcedLength}, | |||
| 289 | + | }, | |||
| 290 | + | { | |||
| 291 | + | name: "invalid status attempt", raw: `{"version":1,"reason":"status_rescue","rescue_attempts":3}`}, | |||
| 292 | + | {name: "unknown reason", raw: `{"version":1,"reason":"other"}`}, | |||
| 293 | + | {name: "invalid JSON", raw: `{`}, | |||
| 294 | + | {name: "missing", raw: ""}, | |||
| 295 | + | } | |||
| 296 | + | for _, tt := range tests { | |||
| 297 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 298 | + | if got := decodeCompactHandoff([]byte(tt.raw)); got != tt.want { | |||
| 299 | + | t.Fatalf("decodeCompactHandoff(%q) = %#v, want %#v", tt.raw, got, tt.want) | |||
| 300 | + | } | |||
| 301 | + | }) | |||
| 302 | + | } | |||
| 303 | + | } | |||
| 304 | + | ||||
| 236 | func TestShouldAutoCompactUsesConfiguredThreshold(t *testing.T) { | 305 | func TestShouldAutoCompactUsesConfiguredThreshold(t *testing.T) { | ||
| 237 | tests := []struct { | 306 | tests := []struct { | ||
| 238 | name string | 307 | name string | ||
M packages/agent/modes/compact_queue_test.go +209 -0
| @@ -2,6 +2,7 @@ package modes | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "encoding/json" | |
| 5 | 6 | "errors" | |
| 6 | 7 | "sync" | |
| 7 | 8 | "testing" | |
| @@ -257,6 +258,199 @@ func (c *thresholdAutoCompactClient) Stream(ctx context.Context, req provider.Re | |||
| 257 | 258 | return out, nil | |
| 258 | 259 | } | |
| 259 | 260 | ||
| 261 | + | type restoredHandoffClient struct { | |
| 262 | + | requests chan provider.Request | |
| 263 | + | replies []string | |
| 264 | + | ||
| 265 | + | mu sync.Mutex | |
| 266 | + | calls int | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | func (c *restoredHandoffClient) Name() string { return "restored-handoff-test" } | |
| 270 | + | ||
| 271 | + | func (c *restoredHandoffClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) { | |
| 272 | + | c.mu.Lock() | |
| 273 | + | call := c.calls | |
| 274 | + | c.calls++ | |
| 275 | + | text := "done" | |
| 276 | + | if call < len(c.replies) { | |
| 277 | + | text = c.replies[call] | |
| 278 | + | } | |
| 279 | + | c.mu.Unlock() | |
| 280 | + | ||
| 281 | + | out := make(chan provider.Event, 1) | |
| 282 | + | go func() { | |
| 283 | + | defer close(out) | |
| 284 | + | c.requests <- req | |
| 285 | + | out <- provider.EventDone{ | |
| 286 | + | Stop: provider.StopEnd, | |
| 287 | + | Message: provider.Message{ | |
| 288 | + | Role: provider.RoleAssistant, | |
| 289 | + | Content: []provider.Content{provider.TextBlock{Text: text}}, | |
| 290 | + | }, | |
| 291 | + | } | |
| 292 | + | }() | |
| 293 | + | return out, nil | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | func TestRestoredStatusHandoffContinuesExistingPromptWithoutDuplication(t *testing.T) { | |
| 297 | + | client := &restoredHandoffClient{ | |
| 298 | + | requests: make(chan provider.Request, 2), | |
| 299 | + | replies: []string{"Next I will run the targeted tests.", "All required work is complete."}, | |
| 300 | + | } | |
| 301 | + | agent := core.NewAgent(client, "test-model", "", nil) | |
| 302 | + | agent.SetMessages([]provider.Message{{ | |
| 303 | + | Role: provider.RoleUser, | |
| 304 | + | Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}}, | |
| 305 | + | Meta: map[string]string{autoCompactContinueMetaKey: "true"}, | |
| 306 | + | }}) | |
| 307 | + | interactive := NewInteractive(InteractiveConfig{ | |
| 308 | + | Agent: agent, | |
| 309 | + | InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`), | |
| 310 | + | }) | |
| 311 | + | interactive.runCtx = context.Background() | |
| 312 | + | ||
| 313 | + | interactive.startRestoredCompactHandoff(context.Background()) | |
| 314 | + | for attempt := 1; attempt <= 2; attempt++ { | |
| 315 | + | select { | |
| 316 | + | case req := <-client.requests: | |
| 317 | + | if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != attempt { | |
| 318 | + | t.Fatalf("request %d continuation prompt count = %d, want %d: %#v", attempt, got, attempt, req.Messages) | |
| 319 | + | } | |
| 320 | + | case <-time.After(2 * time.Second): | |
| 321 | + | t.Fatalf("restored handoff request %d did not start", attempt) | |
| 322 | + | } | |
| 323 | + | } | |
| 324 | + | waitInteractiveIdle(t, interactive) | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | func TestRestoredHandoffAppendsMissingContinuationPrompt(t *testing.T) { | |
| 328 | + | client := &restoredHandoffClient{requests: make(chan provider.Request, 1)} | |
| 329 | + | agent := core.NewAgent(client, "test-model", "", nil) | |
| 330 | + | interactive := NewInteractive(InteractiveConfig{ | |
| 331 | + | Agent: agent, | |
| 332 | + | InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"structural_tail"}`), | |
| 333 | + | }) | |
| 334 | + | interactive.runCtx = context.Background() | |
| 335 | + | ||
| 336 | + | interactive.startRestoredCompactHandoff(context.Background()) | |
| 337 | + | select { | |
| 338 | + | case req := <-client.requests: | |
| 339 | + | if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 { | |
| 340 | + | t.Fatalf("restored request continuation prompt count = %d, want 1: %#v", got, req.Messages) | |
| 341 | + | } | |
| 342 | + | case <-time.After(2 * time.Second): | |
| 343 | + | t.Fatal("restored handoff did not append a missing continuation prompt") | |
| 344 | + | } | |
| 345 | + | waitInteractiveIdle(t, interactive) | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | func TestRestoredHandoffAfterFinalReplyIsDiscarded(t *testing.T) { | |
| 349 | + | persisted := make(chan string, 1) | |
| 350 | + | client := &restoredHandoffClient{requests: make(chan provider.Request, 1)} | |
| 351 | + | agent := core.NewAgent(client, "test-model", "", nil) | |
| 352 | + | agent.SetMessages([]provider.Message{ | |
| 353 | + | { | |
| 354 | + | Role: provider.RoleUser, | |
| 355 | + | Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}}, | |
| 356 | + | Meta: map[string]string{autoCompactContinueMetaKey: "true"}, | |
| 357 | + | }, | |
| 358 | + | { | |
| 359 | + | Role: provider.RoleAssistant, | |
| 360 | + | Content: []provider.Content{provider.TextBlock{Text: "All required work is complete."}}, | |
| 361 | + | }, | |
| 362 | + | }) | |
| 363 | + | interactive := NewInteractive(InteractiveConfig{ | |
| 364 | + | Agent: agent, | |
| 365 | + | InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`), | |
| 366 | + | PersistCompactHandoff: func(state json.RawMessage) error { | |
| 367 | + | persisted <- string(state) | |
| 368 | + | return nil | |
| 369 | + | }, | |
| 370 | + | }) | |
| 371 | + | ||
| 372 | + | interactive.startRestoredCompactHandoff(context.Background()) | |
| 373 | + | select { | |
| 374 | + | case state := <-persisted: | |
| 375 | + | if state != "" { | |
| 376 | + | t.Fatalf("cleared handoff = %q, want empty", state) | |
| 377 | + | } | |
| 378 | + | case <-time.After(2 * time.Second): | |
| 379 | + | t.Fatal("stale handoff was not cleared") | |
| 380 | + | } | |
| 381 | + | select { | |
| 382 | + | case req := <-client.requests: | |
| 383 | + | t.Fatalf("stale handoff started a provider request: %#v", req.Messages) | |
| 384 | + | default: | |
| 385 | + | } | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | func TestRestoredHandoffDefersToQueuedUserPrompt(t *testing.T) { | |
| 389 | + | client := &restoredHandoffClient{requests: make(chan provider.Request, 1)} | |
| 390 | + | agent := core.NewAgent(client, "test-model", "", nil) | |
| 391 | + | agent.SetMessages([]provider.Message{{ | |
| 392 | + | Role: provider.RoleUser, | |
| 393 | + | Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}}, | |
| 394 | + | Meta: map[string]string{autoCompactContinueMetaKey: "true"}, | |
| 395 | + | }}) | |
| 396 | + | interactive := NewInteractive(InteractiveConfig{ | |
| 397 | + | Agent: agent, | |
| 398 | + | InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`), | |
| 399 | + | }) | |
| 400 | + | interactive.runCtx = context.Background() | |
| 401 | + | interactive.mu.Lock() | |
| 402 | + | interactive.queued = []string{"newer user request"} | |
| 403 | + | interactive.mu.Unlock() | |
| 404 | + | ||
| 405 | + | interactive.startRestoredCompactHandoff(context.Background()) | |
| 406 | + | select { | |
| 407 | + | case req := <-client.requests: | |
| 408 | + | if got := requestUserTextCount(req, "newer user request"); got != 1 { | |
| 409 | + | t.Fatalf("newer user request count = %d, want 1: %#v", got, req.Messages) | |
| 410 | + | } | |
| 411 | + | if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 { | |
| 412 | + | t.Fatalf("restored continuation prompt count = %d, want only the existing prompt: %#v", got, req.Messages) | |
| 413 | + | } | |
| 414 | + | case <-time.After(2 * time.Second): | |
| 415 | + | t.Fatal("queued user prompt did not run instead of restored handoff") | |
| 416 | + | } | |
| 417 | + | waitInteractiveIdle(t, interactive) | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | func TestRestoredExhaustedStatusHandoffDoesNotContinueAgain(t *testing.T) { | |
| 421 | + | client := &restoredHandoffClient{ | |
| 422 | + | requests: make(chan provider.Request, 2), | |
| 423 | + | replies: []string{"Next I will run the targeted tests."}, | |
| 424 | + | } | |
| 425 | + | agent := core.NewAgent(client, "test-model", "", nil) | |
| 426 | + | agent.SetMessages([]provider.Message{{ | |
| 427 | + | Role: provider.RoleUser, | |
| 428 | + | Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}}, | |
| 429 | + | Meta: map[string]string{autoCompactContinueMetaKey: "true"}, | |
| 430 | + | }}) | |
| 431 | + | interactive := NewInteractive(InteractiveConfig{ | |
| 432 | + | Agent: agent, | |
| 433 | + | InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":2}`), | |
| 434 | + | }) | |
| 435 | + | interactive.runCtx = context.Background() | |
| 436 | + | ||
| 437 | + | interactive.startRestoredCompactHandoff(context.Background()) | |
| 438 | + | select { | |
| 439 | + | case req := <-client.requests: | |
| 440 | + | if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 { | |
| 441 | + | t.Fatalf("restored request continuation prompt count = %d, want 1: %#v", got, req.Messages) | |
| 442 | + | } | |
| 443 | + | case <-time.After(2 * time.Second): | |
| 444 | + | t.Fatal("restored handoff did not start") | |
| 445 | + | } | |
| 446 | + | waitInteractiveIdle(t, interactive) | |
| 447 | + | select { | |
| 448 | + | case req := <-client.requests: | |
| 449 | + | t.Fatalf("exhausted handoff started another request: %#v", req.Messages) | |
| 450 | + | default: | |
| 451 | + | } | |
| 452 | + | } | |
| 453 | + | ||
| 260 | 454 | func TestThresholdAutoCompactionContinuesMostRecentIntent(t *testing.T) { | |
| 261 | 455 | client := &thresholdAutoCompactClient{ | |
| 262 | 456 | compactionStarted: make(chan struct{}), | |
| @@ -292,6 +486,7 @@ func TestThresholdAutoCompactionContinuesMostRecentIntent(t *testing.T) { | |||
| 292 | 486 | } | |
| 293 | 487 | ||
| 294 | 488 | func TestThresholdAutoCompactionRescuesStatusAfterCompaction(t *testing.T) { | |
| 489 | + | persistedHandoff := make(chan string, 1) | |
| 295 | 490 | client := &thresholdAutoCompactClient{ | |
| 296 | 491 | compactionStarted: make(chan struct{}), | |
| 297 | 492 | releaseCompaction: make(chan struct{}), | |
| @@ -307,6 +502,10 @@ func TestThresholdAutoCompactionRescuesStatusAfterCompaction(t *testing.T) { | |||
| 307 | 502 | Provider: "anthropic", | |
| 308 | 503 | Model: "claude-sonnet-4-5-20250929", | |
| 309 | 504 | AutoCompactThreshold: &threshold, | |
| 505 | + | PersistCompactHandoff: func(state json.RawMessage) error { | |
| 506 | + | persistedHandoff <- string(state) | |
| 507 | + | return nil | |
| 508 | + | }, | |
| 310 | 509 | }) | |
| 311 | 510 | interactive.runCtx = context.Background() | |
| 312 | 511 | ||
| @@ -318,6 +517,16 @@ func TestThresholdAutoCompactionRescuesStatusAfterCompaction(t *testing.T) { | |||
| 318 | 517 | } | |
| 319 | 518 | close(client.releaseCompaction) | |
| 320 | 519 | ||
| 520 | + | select { | |
| 521 | + | case state := <-persistedHandoff: | |
| 522 | + | want := `{"version":1,"reason":"status_rescue","rescue_attempts":1}` | |
| 523 | + | if state != want { | |
| 524 | + | t.Fatalf("persisted handoff = %q, want %q", state, want) | |
| 525 | + | } | |
| 526 | + | case <-time.After(2 * time.Second): | |
| 527 | + | t.Fatal("status rescue handoff was not persisted") | |
| 528 | + | } | |
| 529 | + | ||
| 321 | 530 | select { | |
| 322 | 531 | case req := <-client.followUpRequest: | |
| 323 | 532 | if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 { | |
| @@ -2,6 +2,7 @@ package modes | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "encoding/json" | |||
| 5 | "errors" | 6 | "errors" | ||
| 6 | "sync" | 7 | "sync" | ||
| 7 | "testing" | 8 | "testing" | ||
| @@ -257,6 +258,199 @@ func (c *thresholdAutoCompactClient) Stream(ctx context.Context, req provider.Re | |||||
| 257 | return out, nil | 258 | return out, nil | ||
| 258 | } | 259 | } | ||
| 259 | 260 | ||||
| 261 | + | type restoredHandoffClient struct { | |||
| 262 | + | requests chan provider.Request | |||
| 263 | + | replies []string | |||
| 264 | + | ||||
| 265 | + | mu sync.Mutex | |||
| 266 | + | calls int | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | func (c *restoredHandoffClient) Name() string { return "restored-handoff-test" } | |||
| 270 | + | ||||
| 271 | + | func (c *restoredHandoffClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) { | |||
| 272 | + | c.mu.Lock() | |||
| 273 | + | call := c.calls | |||
| 274 | + | c.calls++ | |||
| 275 | + | text := "done" | |||
| 276 | + | if call < len(c.replies) { | |||
| 277 | + | text = c.replies[call] | |||
| 278 | + | } | |||
| 279 | + | c.mu.Unlock() | |||
| 280 | + | ||||
| 281 | + | out := make(chan provider.Event, 1) | |||
| 282 | + | go func() { | |||
| 283 | + | defer close(out) | |||
| 284 | + | c.requests <- req | |||
| 285 | + | out <- provider.EventDone{ | |||
| 286 | + | Stop: provider.StopEnd, | |||
| 287 | + | Message: provider.Message{ | |||
| 288 | + | Role: provider.RoleAssistant, | |||
| 289 | + | Content: []provider.Content{provider.TextBlock{Text: text}}, | |||
| 290 | + | }, | |||
| 291 | + | } | |||
| 292 | + | }() | |||
| 293 | + | return out, nil | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | func TestRestoredStatusHandoffContinuesExistingPromptWithoutDuplication(t *testing.T) { | |||
| 297 | + | client := &restoredHandoffClient{ | |||
| 298 | + | requests: make(chan provider.Request, 2), | |||
| 299 | + | replies: []string{"Next I will run the targeted tests.", "All required work is complete."}, | |||
| 300 | + | } | |||
| 301 | + | agent := core.NewAgent(client, "test-model", "", nil) | |||
| 302 | + | agent.SetMessages([]provider.Message{{ | |||
| 303 | + | Role: provider.RoleUser, | |||
| 304 | + | Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}}, | |||
| 305 | + | Meta: map[string]string{autoCompactContinueMetaKey: "true"}, | |||
| 306 | + | }}) | |||
| 307 | + | interactive := NewInteractive(InteractiveConfig{ | |||
| 308 | + | Agent: agent, | |||
| 309 | + | InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`), | |||
| 310 | + | }) | |||
| 311 | + | interactive.runCtx = context.Background() | |||
| 312 | + | ||||
| 313 | + | interactive.startRestoredCompactHandoff(context.Background()) | |||
| 314 | + | for attempt := 1; attempt <= 2; attempt++ { | |||
| 315 | + | select { | |||
| 316 | + | case req := <-client.requests: | |||
| 317 | + | if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != attempt { | |||
| 318 | + | t.Fatalf("request %d continuation prompt count = %d, want %d: %#v", attempt, got, attempt, req.Messages) | |||
| 319 | + | } | |||
| 320 | + | case <-time.After(2 * time.Second): | |||
| 321 | + | t.Fatalf("restored handoff request %d did not start", attempt) | |||
| 322 | + | } | |||
| 323 | + | } | |||
| 324 | + | waitInteractiveIdle(t, interactive) | |||
| 325 | + | } | |||
| 326 | + | ||||
| 327 | + | func TestRestoredHandoffAppendsMissingContinuationPrompt(t *testing.T) { | |||
| 328 | + | client := &restoredHandoffClient{requests: make(chan provider.Request, 1)} | |||
| 329 | + | agent := core.NewAgent(client, "test-model", "", nil) | |||
| 330 | + | interactive := NewInteractive(InteractiveConfig{ | |||
| 331 | + | Agent: agent, | |||
| 332 | + | InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"structural_tail"}`), | |||
| 333 | + | }) | |||
| 334 | + | interactive.runCtx = context.Background() | |||
| 335 | + | ||||
| 336 | + | interactive.startRestoredCompactHandoff(context.Background()) | |||
| 337 | + | select { | |||
| 338 | + | case req := <-client.requests: | |||
| 339 | + | if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 { | |||
| 340 | + | t.Fatalf("restored request continuation prompt count = %d, want 1: %#v", got, req.Messages) | |||
| 341 | + | } | |||
| 342 | + | case <-time.After(2 * time.Second): | |||
| 343 | + | t.Fatal("restored handoff did not append a missing continuation prompt") | |||
| 344 | + | } | |||
| 345 | + | waitInteractiveIdle(t, interactive) | |||
| 346 | + | } | |||
| 347 | + | ||||
| 348 | + | func TestRestoredHandoffAfterFinalReplyIsDiscarded(t *testing.T) { | |||
| 349 | + | persisted := make(chan string, 1) | |||
| 350 | + | client := &restoredHandoffClient{requests: make(chan provider.Request, 1)} | |||
| 351 | + | agent := core.NewAgent(client, "test-model", "", nil) | |||
| 352 | + | agent.SetMessages([]provider.Message{ | |||
| 353 | + | { | |||
| 354 | + | Role: provider.RoleUser, | |||
| 355 | + | Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}}, | |||
| 356 | + | Meta: map[string]string{autoCompactContinueMetaKey: "true"}, | |||
| 357 | + | }, | |||
| 358 | + | { | |||
| 359 | + | Role: provider.RoleAssistant, | |||
| 360 | + | Content: []provider.Content{provider.TextBlock{Text: "All required work is complete."}}, | |||
| 361 | + | }, | |||
| 362 | + | }) | |||
| 363 | + | interactive := NewInteractive(InteractiveConfig{ | |||
| 364 | + | Agent: agent, | |||
| 365 | + | InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`), | |||
| 366 | + | PersistCompactHandoff: func(state json.RawMessage) error { | |||
| 367 | + | persisted <- string(state) | |||
| 368 | + | return nil | |||
| 369 | + | }, | |||
| 370 | + | }) | |||
| 371 | + | ||||
| 372 | + | interactive.startRestoredCompactHandoff(context.Background()) | |||
| 373 | + | select { | |||
| 374 | + | case state := <-persisted: | |||
| 375 | + | if state != "" { | |||
| 376 | + | t.Fatalf("cleared handoff = %q, want empty", state) | |||
| 377 | + | } | |||
| 378 | + | case <-time.After(2 * time.Second): | |||
| 379 | + | t.Fatal("stale handoff was not cleared") | |||
| 380 | + | } | |||
| 381 | + | select { | |||
| 382 | + | case req := <-client.requests: | |||
| 383 | + | t.Fatalf("stale handoff started a provider request: %#v", req.Messages) | |||
| 384 | + | default: | |||
| 385 | + | } | |||
| 386 | + | } | |||
| 387 | + | ||||
| 388 | + | func TestRestoredHandoffDefersToQueuedUserPrompt(t *testing.T) { | |||
| 389 | + | client := &restoredHandoffClient{requests: make(chan provider.Request, 1)} | |||
| 390 | + | agent := core.NewAgent(client, "test-model", "", nil) | |||
| 391 | + | agent.SetMessages([]provider.Message{{ | |||
| 392 | + | Role: provider.RoleUser, | |||
| 393 | + | Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}}, | |||
| 394 | + | Meta: map[string]string{autoCompactContinueMetaKey: "true"}, | |||
| 395 | + | }}) | |||
| 396 | + | interactive := NewInteractive(InteractiveConfig{ | |||
| 397 | + | Agent: agent, | |||
| 398 | + | InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":1}`), | |||
| 399 | + | }) | |||
| 400 | + | interactive.runCtx = context.Background() | |||
| 401 | + | interactive.mu.Lock() | |||
| 402 | + | interactive.queued = []string{"newer user request"} | |||
| 403 | + | interactive.mu.Unlock() | |||
| 404 | + | ||||
| 405 | + | interactive.startRestoredCompactHandoff(context.Background()) | |||
| 406 | + | select { | |||
| 407 | + | case req := <-client.requests: | |||
| 408 | + | if got := requestUserTextCount(req, "newer user request"); got != 1 { | |||
| 409 | + | t.Fatalf("newer user request count = %d, want 1: %#v", got, req.Messages) | |||
| 410 | + | } | |||
| 411 | + | if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 { | |||
| 412 | + | t.Fatalf("restored continuation prompt count = %d, want only the existing prompt: %#v", got, req.Messages) | |||
| 413 | + | } | |||
| 414 | + | case <-time.After(2 * time.Second): | |||
| 415 | + | t.Fatal("queued user prompt did not run instead of restored handoff") | |||
| 416 | + | } | |||
| 417 | + | waitInteractiveIdle(t, interactive) | |||
| 418 | + | } | |||
| 419 | + | ||||
| 420 | + | func TestRestoredExhaustedStatusHandoffDoesNotContinueAgain(t *testing.T) { | |||
| 421 | + | client := &restoredHandoffClient{ | |||
| 422 | + | requests: make(chan provider.Request, 2), | |||
| 423 | + | replies: []string{"Next I will run the targeted tests."}, | |||
| 424 | + | } | |||
| 425 | + | agent := core.NewAgent(client, "test-model", "", nil) | |||
| 426 | + | agent.SetMessages([]provider.Message{{ | |||
| 427 | + | Role: provider.RoleUser, | |||
| 428 | + | Content: []provider.Content{provider.TextBlock{Text: autoCompactContinuationPrompt}}, | |||
| 429 | + | Meta: map[string]string{autoCompactContinueMetaKey: "true"}, | |||
| 430 | + | }}) | |||
| 431 | + | interactive := NewInteractive(InteractiveConfig{ | |||
| 432 | + | Agent: agent, | |||
| 433 | + | InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"status_rescue","rescue_attempts":2}`), | |||
| 434 | + | }) | |||
| 435 | + | interactive.runCtx = context.Background() | |||
| 436 | + | ||||
| 437 | + | interactive.startRestoredCompactHandoff(context.Background()) | |||
| 438 | + | select { | |||
| 439 | + | case req := <-client.requests: | |||
| 440 | + | if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 { | |||
| 441 | + | t.Fatalf("restored request continuation prompt count = %d, want 1: %#v", got, req.Messages) | |||
| 442 | + | } | |||
| 443 | + | case <-time.After(2 * time.Second): | |||
| 444 | + | t.Fatal("restored handoff did not start") | |||
| 445 | + | } | |||
| 446 | + | waitInteractiveIdle(t, interactive) | |||
| 447 | + | select { | |||
| 448 | + | case req := <-client.requests: | |||
| 449 | + | t.Fatalf("exhausted handoff started another request: %#v", req.Messages) | |||
| 450 | + | default: | |||
| 451 | + | } | |||
| 452 | + | } | |||
| 453 | + | ||||
| 260 | func TestThresholdAutoCompactionContinuesMostRecentIntent(t *testing.T) { | 454 | func TestThresholdAutoCompactionContinuesMostRecentIntent(t *testing.T) { | ||
| 261 | client := &thresholdAutoCompactClient{ | 455 | client := &thresholdAutoCompactClient{ | ||
| 262 | compactionStarted: make(chan struct{}), | 456 | compactionStarted: make(chan struct{}), | ||
| @@ -292,6 +486,7 @@ func TestThresholdAutoCompactionContinuesMostRecentIntent(t *testing.T) { | |||||
| 292 | } | 486 | } | ||
| 293 | 487 | ||||
| 294 | func TestThresholdAutoCompactionRescuesStatusAfterCompaction(t *testing.T) { | 488 | func TestThresholdAutoCompactionRescuesStatusAfterCompaction(t *testing.T) { | ||
| 489 | + | persistedHandoff := make(chan string, 1) | |||
| 295 | client := &thresholdAutoCompactClient{ | 490 | client := &thresholdAutoCompactClient{ | ||
| 296 | compactionStarted: make(chan struct{}), | 491 | compactionStarted: make(chan struct{}), | ||
| 297 | releaseCompaction: make(chan struct{}), | 492 | releaseCompaction: make(chan struct{}), | ||
| @@ -307,6 +502,10 @@ func TestThresholdAutoCompactionRescuesStatusAfterCompaction(t *testing.T) { | |||||
| 307 | Provider: "anthropic", | 502 | Provider: "anthropic", | ||
| 308 | Model: "claude-sonnet-4-5-20250929", | 503 | Model: "claude-sonnet-4-5-20250929", | ||
| 309 | AutoCompactThreshold: &threshold, | 504 | AutoCompactThreshold: &threshold, | ||
| 505 | + | PersistCompactHandoff: func(state json.RawMessage) error { | |||
| 506 | + | persistedHandoff <- string(state) | |||
| 507 | + | return nil | |||
| 508 | + | }, | |||
| 310 | }) | 509 | }) | ||
| 311 | interactive.runCtx = context.Background() | 510 | interactive.runCtx = context.Background() | ||
| 312 | 511 | ||||
| @@ -318,6 +517,16 @@ func TestThresholdAutoCompactionRescuesStatusAfterCompaction(t *testing.T) { | |||||
| 318 | } | 517 | } | ||
| 319 | close(client.releaseCompaction) | 518 | close(client.releaseCompaction) | ||
| 320 | 519 | ||||
| 520 | + | select { | |||
| 521 | + | case state := <-persistedHandoff: | |||
| 522 | + | want := `{"version":1,"reason":"status_rescue","rescue_attempts":1}` | |||
| 523 | + | if state != want { | |||
| 524 | + | t.Fatalf("persisted handoff = %q, want %q", state, want) | |||
| 525 | + | } | |||
| 526 | + | case <-time.After(2 * time.Second): | |||
| 527 | + | t.Fatal("status rescue handoff was not persisted") | |||
| 528 | + | } | |||
| 529 | + | ||||
| 321 | select { | 530 | select { | ||
| 322 | case req := <-client.followUpRequest: | 531 | case req := <-client.followUpRequest: | ||
| 323 | if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 { | 532 | if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 { | ||
M packages/agent/modes/interactive.go +419 -81
| @@ -188,6 +188,16 @@ type InteractiveConfig struct { | |||
| 188 | 188 | // user must /login before they can prompt. | |
| 189 | 189 | 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 | 201 | InitialInput string | |
| 192 | 202 | ||
| 193 | 203 | // StartupPre is auto-submitted once when the interactive session | |
| @@ -526,11 +536,64 @@ const ( | |||
| 526 | 536 | ||
| 527 | 537 | const maxStatusRescueContinuations = 2 | |
| 528 | 538 | ||
| 539 | + | const compactHandoffVersion = 1 | |
| 540 | + | ||
| 529 | 541 | type compactContinuationState struct { | |
| 530 | 542 | reason compactContinuationReason | |
| 531 | 543 | 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 | 597 | type Interactive struct { | |
| 535 | 598 | cfg InteractiveConfig | |
| 536 | 599 | view *tui.View | |
| @@ -850,38 +913,39 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 850 | 913 | // Prompt is the standard half-block accent bar used by chat | |
| 851 | 914 | // speaker labels too, so the input gutter matches the rest | |
| 852 | 915 | // 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), | |
| 885 | 949 | } | |
| 886 | 950 | i.btwDialog.setCloseHook(func() { | |
| 887 | 951 | i.confirmDialog.CancelChildConfirmations("side chat closed") | |
| @@ -897,7 +961,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 897 | 961 | } | |
| 898 | 962 | if cfg.Agent != nil { | |
| 899 | 963 | i.agent = cfg.Agent | |
| 900 | - | i.view.Messages = cfg.Agent.Messages() | |
| 964 | + | i.view.Messages = filterHiddenTranscriptMessages(cfg.Agent.Messages()) | |
| 901 | 965 | i.cumUsage = cfg.Agent.Cost() | |
| 902 | 966 | // Rehydrate the "context used" gauge from the last persisted | |
| 903 | 967 | // 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 | 1056 | i.Submit(i.cfg.StartupPre) | |
| 993 | 1057 | } else if i.cfg.AutoSubmitInitial && i.cfg.InitialInput != "" { | |
| 994 | 1058 | 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) | |
| 997 | 1064 | } | |
| 998 | 1065 | ||
| 999 | 1066 | // Stamp the welcome time and schedule a one-shot redraw at the | |
| @@ -3086,10 +3153,15 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 3086 | 3153 | i.mu.Lock() | |
| 3087 | 3154 | busyCancel := i.busy && i.cancelTurn != nil | |
| 3088 | 3155 | cancelTurn := i.cancelTurn | |
| 3156 | + | var handoff json.RawMessage | |
| 3157 | + | var persistHandoff bool | |
| 3089 | 3158 | if busyCancel { | |
| 3090 | - | i.resetCompactContinuationLocked() | |
| 3159 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 3091 | 3160 | } | |
| 3092 | 3161 | i.mu.Unlock() | |
| 3162 | + | if persistHandoff { | |
| 3163 | + | i.persistCompactHandoff(handoff) | |
| 3164 | + | } | |
| 3093 | 3165 | if busyCancel { | |
| 3094 | 3166 | cancelTurn() | |
| 3095 | 3167 | // If a confirm dialog is pending, refuse it so the agent | |
| @@ -3403,17 +3475,22 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 3403 | 3475 | return false | |
| 3404 | 3476 | } | |
| 3405 | 3477 | clearSubmittedInput() | |
| 3478 | + | var handoff json.RawMessage | |
| 3479 | + | var persistHandoff bool | |
| 3406 | 3480 | if ag != nil && !compacting { | |
| 3407 | 3481 | i.mu.Lock() | |
| 3408 | - | i.resetCompactContinuationLocked() | |
| 3482 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 3409 | 3483 | ag.QueueMessage(text) | |
| 3410 | 3484 | i.mu.Unlock() | |
| 3411 | 3485 | } else { | |
| 3412 | 3486 | i.mu.Lock() | |
| 3413 | - | i.resetCompactContinuationLocked() | |
| 3487 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 3414 | 3488 | i.queued = append(i.queued, text) | |
| 3415 | 3489 | i.mu.Unlock() | |
| 3416 | 3490 | } | |
| 3491 | + | if persistHandoff { | |
| 3492 | + | i.persistCompactHandoff(handoff) | |
| 3493 | + | } | |
| 3417 | 3494 | i.invalidate() | |
| 3418 | 3495 | return false | |
| 3419 | 3496 | } | |
| @@ -3755,6 +3832,10 @@ func (i *Interactive) applyStartupPreResult(result startupPreResult) { | |||
| 3755 | 3832 | // Unlike ApplyChangedCWD it does not mutate cwd-scoped resources or startup | |
| 3756 | 3833 | // context; the session loader is changing the transcript/provider only. | |
| 3757 | 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 | 3839 | if ag == nil { | |
| 3759 | 3840 | return | |
| 3760 | 3841 | } | |
| @@ -3762,6 +3843,7 @@ func (i *Interactive) ApplySessionAgent(ag *core.Agent, providerName, model stri | |||
| 3762 | 3843 | defer i.agentMu.Unlock() | |
| 3763 | 3844 | i.mu.Lock() | |
| 3764 | 3845 | i.prepareReplacementAgentLocked(ag) | |
| 3846 | + | i.compactContinuation = decodeCompactHandoff(compactHandoff) | |
| 3765 | 3847 | i.agent = ag | |
| 3766 | 3848 | i.cfg.Provider = providerName | |
| 3767 | 3849 | i.cfg.Model = model | |
| @@ -3819,6 +3901,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin | |||
| 3819 | 3901 | defer i.agentMu.Unlock() | |
| 3820 | 3902 | i.mu.Lock() | |
| 3821 | 3903 | i.prepareReplacementAgentLocked(ag) | |
| 3904 | + | i.resetCompactContinuationLocked() | |
| 3822 | 3905 | i.agent = ag | |
| 3823 | 3906 | i.cfg.CWD = cwd | |
| 3824 | 3907 | i.cfg.SubagentsSystemAddendum = subagentsAddendum | |
| @@ -3913,14 +3996,19 @@ func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) { | |||
| 3913 | 3996 | // cannot inspect the agent queue between the busy check and enqueue. | |
| 3914 | 3997 | // Compaction uses the host queue because it has no active agent loop | |
| 3915 | 3998 | // to drain Agent.QueueMessage entries. | |
| 3999 | + | var handoff json.RawMessage | |
| 4000 | + | var persistHandoff bool | |
| 3916 | 4001 | if i.agent != nil && !i.compacting { | |
| 3917 | - | i.resetCompactContinuationLocked() | |
| 4002 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 3918 | 4003 | i.agent.QueueMessage(text) | |
| 3919 | 4004 | } else { | |
| 3920 | - | i.resetCompactContinuationLocked() | |
| 4005 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 3921 | 4006 | i.queued = append(i.queued, text) | |
| 3922 | 4007 | } | |
| 3923 | 4008 | i.mu.Unlock() | |
| 4009 | + | if persistHandoff { | |
| 4010 | + | i.persistCompactHandoff(handoff) | |
| 4011 | + | } | |
| 3924 | 4012 | i.invalidate() | |
| 3925 | 4013 | return | |
| 3926 | 4014 | } | |
| @@ -3946,8 +4034,11 @@ func (i *Interactive) CancelTurn() { | |||
| 3946 | 4034 | i.mu.Unlock() | |
| 3947 | 4035 | if cancel != nil { | |
| 3948 | 4036 | i.mu.Lock() | |
| 3949 | - | i.resetCompactContinuationLocked() | |
| 4037 | + | handoff, persistHandoff := i.resetCompactContinuationLocked() | |
| 3950 | 4038 | i.mu.Unlock() | |
| 4039 | + | if persistHandoff { | |
| 4040 | + | i.persistCompactHandoff(handoff) | |
| 4041 | + | } | |
| 3951 | 4042 | cancel() | |
| 3952 | 4043 | i.confirmDialog.CancelAll("turn cancelled") | |
| 3953 | 4044 | } | |
| @@ -5712,9 +5803,12 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 5712 | 5803 | i.scrollOffset = 0 | |
| 5713 | 5804 | i.extNotes = nil | |
| 5714 | 5805 | i.reloadErrors = nil | |
| 5715 | - | i.resetCompactContinuationLocked() | |
| 5806 | + | handoff, persistHandoff := i.resetCompactContinuationLocked() | |
| 5716 | 5807 | i.view.InvalidateRenderCache() | |
| 5717 | 5808 | i.mu.Unlock() | |
| 5809 | + | if persistHandoff { | |
| 5810 | + | i.persistCompactHandoff(handoff) | |
| 5811 | + | } | |
| 5718 | 5812 | case "/help": | |
| 5719 | 5813 | i.mu.Lock() | |
| 5720 | 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 | 5905 | ag := i.agent | |
| 5812 | 5906 | i.mu.Unlock() | |
| 5813 | 5907 | if busy { | |
| 5908 | + | var handoff json.RawMessage | |
| 5909 | + | var persistHandoff bool | |
| 5814 | 5910 | if ag != nil && !compacting { | |
| 5815 | 5911 | i.mu.Lock() | |
| 5816 | - | i.resetCompactContinuationLocked() | |
| 5912 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 5817 | 5913 | ag.QueueMessage(studyPrompt) | |
| 5818 | 5914 | i.mu.Unlock() | |
| 5819 | 5915 | } else { | |
| 5820 | 5916 | i.mu.Lock() | |
| 5821 | - | i.resetCompactContinuationLocked() | |
| 5917 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 5822 | 5918 | i.queued = append(i.queued, studyPrompt) | |
| 5823 | 5919 | i.mu.Unlock() | |
| 5824 | 5920 | } | |
| 5921 | + | if persistHandoff { | |
| 5922 | + | i.persistCompactHandoff(handoff) | |
| 5923 | + | } | |
| 5825 | 5924 | i.invalidate() | |
| 5826 | 5925 | break | |
| 5827 | 5926 | } | |
| @@ -6104,13 +6203,15 @@ func (i *Interactive) doLogout(target string) { | |||
| 6104 | 6203 | } | |
| 6105 | 6204 | ||
| 6106 | 6205 | i.mu.Lock() | |
| 6107 | - | defer i.mu.Unlock() | |
| 6108 | 6206 | i.llamaConfigured = llamaConfigured | |
| 6109 | 6207 | if len(errs) > 0 { | |
| 6110 | 6208 | i.statusErr = "logout errors: " + strings.Join(errs, "; ") | |
| 6209 | + | i.mu.Unlock() | |
| 6111 | 6210 | return | |
| 6112 | 6211 | } | |
| 6113 | 6212 | i.statusErr = "" | |
| 6213 | + | var handoff json.RawMessage | |
| 6214 | + | var persistHandoff bool | |
| 6114 | 6215 | if clearedCurrent { | |
| 6115 | 6216 | // The running agent was using a credential we just wiped. Drop | |
| 6116 | 6217 | // 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 | 6220 | _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot()) | |
| 6120 | 6221 | } | |
| 6121 | 6222 | i.agent = nil | |
| 6122 | - | i.resetCompactContinuationLocked() | |
| 6223 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 6123 | 6224 | i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in." | |
| 6124 | 6225 | } else { | |
| 6125 | 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 | 6234 | func providerSetupInfo(provider string) (string, []string, bool) { | |
| @@ -6306,8 +6411,11 @@ func (i *Interactive) cancelAndWaitForIdle() { | |||
| 6306 | 6411 | } | |
| 6307 | 6412 | if cancel != nil { | |
| 6308 | 6413 | i.mu.Lock() | |
| 6309 | - | i.resetCompactContinuationLocked() | |
| 6414 | + | handoff, persistHandoff := i.resetCompactContinuationLocked() | |
| 6310 | 6415 | i.mu.Unlock() | |
| 6416 | + | if persistHandoff { | |
| 6417 | + | i.persistCompactHandoff(handoff) | |
| 6418 | + | } | |
| 6311 | 6419 | cancel() | |
| 6312 | 6420 | } | |
| 6313 | 6421 | // ConfirmToolCall waits on its response channel rather than the turn | |
| @@ -6371,14 +6479,19 @@ func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) { | |||
| 6371 | 6479 | // Keep the mutex held while enqueueing so the turn-completion | |
| 6372 | 6480 | // goroutine cannot publish idle and inspect an empty queue between | |
| 6373 | 6481 | // this check and the append. | |
| 6482 | + | var handoff json.RawMessage | |
| 6483 | + | var persistHandoff bool | |
| 6374 | 6484 | if i.compacting { | |
| 6375 | - | i.resetCompactContinuationLocked() | |
| 6485 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 6376 | 6486 | i.queued = append(i.queued, prompt) | |
| 6377 | 6487 | } else { | |
| 6378 | - | i.resetCompactContinuationLocked() | |
| 6488 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 6379 | 6489 | i.agent.QueueMessage(prompt) | |
| 6380 | 6490 | } | |
| 6381 | 6491 | i.mu.Unlock() | |
| 6492 | + | if persistHandoff { | |
| 6493 | + | i.persistCompactHandoff(handoff) | |
| 6494 | + | } | |
| 6382 | 6495 | i.invalidate() | |
| 6383 | 6496 | return | |
| 6384 | 6497 | } | |
| @@ -6525,7 +6638,9 @@ func (i *Interactive) applySessionSelection(path string) { | |||
| 6525 | 6638 | return | |
| 6526 | 6639 | } | |
| 6527 | 6640 | i.restoreLoadedSessionTitle() | |
| 6641 | + | state := decodeCompactHandoff(i.currentCompactHandoff()) | |
| 6528 | 6642 | i.mu.Lock() | |
| 6643 | + | i.compactContinuation = state | |
| 6529 | 6644 | i.sessionLoading = false | |
| 6530 | 6645 | i.statusOK = "resumed session: " + path | |
| 6531 | 6646 | i.statusErr = "" | |
| @@ -6540,7 +6655,7 @@ func (i *Interactive) applySessionSelection(path string) { | |||
| 6540 | 6655 | i.extNotes = nil | |
| 6541 | 6656 | i.view.InvalidateRenderCache() | |
| 6542 | 6657 | if i.agent != nil { | |
| 6543 | - | i.view.Messages = i.agent.Messages() | |
| 6658 | + | i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages()) | |
| 6544 | 6659 | i.cumUsage = i.agent.Cost() | |
| 6545 | 6660 | if last := i.agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 { | |
| 6546 | 6661 | i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens | |
| @@ -6558,6 +6673,9 @@ func (i *Interactive) applySessionSelection(path string) { | |||
| 6558 | 6673 | } | |
| 6559 | 6674 | i.mu.Unlock() | |
| 6560 | 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 | 6699 | // the supplied builder. rescue=true tags the success message so the | |
| 6582 | 6700 | // user can see that launch-time overrides were ignored. | |
| 6583 | 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 | 6706 | 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() | |
| 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 | 6717 | if model == "" { | |
| 6595 | - | return | |
| 6718 | + | return false | |
| 6596 | 6719 | } | |
| 6597 | 6720 | m, err := provider.FindModel(prov, model) | |
| 6598 | 6721 | if err != nil { | |
| 6599 | 6722 | i.mu.Lock() | |
| 6600 | 6723 | i.statusErr = err.Error() | |
| 6601 | 6724 | i.mu.Unlock() | |
| 6602 | - | return | |
| 6725 | + | return false | |
| 6603 | 6726 | } | |
| 6604 | 6727 | // Same provider AND not a rescue retry: just swap the model on | |
| 6605 | 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 | 6738 | if i.cfg.PersistModel != nil { | |
| 6616 | 6739 | i.cfg.PersistModel(i.cfg.Provider, m.ID) | |
| 6617 | 6740 | } | |
| 6618 | - | return | |
| 6741 | + | return false | |
| 6619 | 6742 | } | |
| 6620 | 6743 | if builder == nil { | |
| 6621 | 6744 | i.mu.Lock() | |
| 6622 | 6745 | i.statusErr = "cannot switch provider: no builder configured" | |
| 6623 | 6746 | i.mu.Unlock() | |
| 6624 | - | return | |
| 6747 | + | return false | |
| 6625 | 6748 | } | |
| 6626 | 6749 | // Snapshot the current transcript and cumulative usage BEFORE we | |
| 6627 | 6750 | // 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 | 6762 | i.mu.Lock() | |
| 6640 | 6763 | i.statusErr = err.Error() | |
| 6641 | 6764 | i.mu.Unlock() | |
| 6642 | - | return | |
| 6765 | + | return false | |
| 6643 | 6766 | } | |
| 6644 | 6767 | ||
| 6645 | 6768 | // 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 | 6805 | if i.cfg.PersistModel != nil { | |
| 6683 | 6806 | i.cfg.PersistModel(p, md) | |
| 6684 | 6807 | } | |
| 6808 | + | return true | |
| 6685 | 6809 | } | |
| 6686 | 6810 | ||
| 6687 | 6811 | func (i *Interactive) handleAuthEvent(ev auth.Event) { | |
| @@ -6697,6 +6821,7 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) { | |||
| 6697 | 6821 | // same transition as settings/session changes. Otherwise a build that | |
| 6698 | 6822 | // started under the old web-search policy could commit afterward. | |
| 6699 | 6823 | var buildErr error | |
| 6824 | + | var clearHandoff bool | |
| 6700 | 6825 | buildAndCommitLogin := func() { | |
| 6701 | 6826 | ag, prov, model, err := i.cfg.BuildAgent() | |
| 6702 | 6827 | if err != nil { | |
| @@ -6707,6 +6832,7 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) { | |||
| 6707 | 6832 | i.mu.Lock() | |
| 6708 | 6833 | oldAgent := i.agent | |
| 6709 | 6834 | i.prepareReplacementAgentLocked(ag) | |
| 6835 | + | _, clearHandoff = i.resetCompactContinuationLocked() | |
| 6710 | 6836 | i.agent = ag | |
| 6711 | 6837 | i.cfg.Provider = prov | |
| 6712 | 6838 | i.cfg.Model = model | |
| @@ -6734,6 +6860,9 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) { | |||
| 6734 | 6860 | i.dialog.ShowResult(false, buildErr.Error()) | |
| 6735 | 6861 | return | |
| 6736 | 6862 | } | |
| 6863 | + | if clearHandoff { | |
| 6864 | + | i.persistCompactHandoff(nil) | |
| 6865 | + | } | |
| 6737 | 6866 | i.dialog.ShowResult(true, "") | |
| 6738 | 6867 | } | |
| 6739 | 6868 | } | |
| @@ -6748,14 +6877,168 @@ func (i *Interactive) clearPendingCompactTurnLocked() { | |||
| 6748 | 6877 | i.pendingPostCompactNote = "" | |
| 6749 | 6878 | } | |
| 6750 | 6879 | ||
| 6751 | - | // resetCompactContinuationLocked clears the private handoff state. The | |
| 6880 | + | // compactHandoffLocked returns a self-contained persistence snapshot. The | |
| 6752 | 6881 | // 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 | |
| 6755 | 6938 | } | |
| 6756 | 6939 | ||
| 6757 | 6940 | const autoCompactContinuationPrompt = `Context was compacted while work was in progress. Continue the user's most recent active request now; do not wait for them to type "continue". Inspect the context summary and kept recent messages, treating active constraints and preferences there as still in force, including delegation/subagent instructions. If a newer request supersedes earlier plans in the summary, follow the newer request. A progress report, plan update, or statement of future work is not completion: if required work remains, take the next concrete action now. Only finish when the request is actually complete or when a specific user decision is required. If nothing remains, give a brief truthful completion; do not invent work or force a tool call.` | |
| 6758 | 6941 | ||
| 6942 | + | type compactHandoffResume uint8 | |
| 6943 | + | ||
| 6944 | + | const ( | |
| 6945 | + | compactHandoffDiscard compactHandoffResume = iota | |
| 6946 | + | compactHandoffContinueExisting | |
| 6947 | + | compactHandoffAppendPrompt | |
| 6948 | + | ) | |
| 6949 | + | ||
| 6950 | + | // classifyCompactHandoffResume validates that the persisted checkpoint still | |
| 6951 | + | // describes the effective transcript. A normal assistant reply or a newer | |
| 6952 | + | // explicit user message supersedes the checkpoint; tool activity remains an | |
| 6953 | + | // unfinished existing turn. No hidden prompt means the process stopped after | |
| 6954 | + | // recording the handoff but before appending its context message. | |
| 6955 | + | func classifyCompactHandoffResume(messages []provider.Message) compactHandoffResume { | |
| 6956 | + | lastHandoff := -1 | |
| 6957 | + | for idx := len(messages) - 1; idx >= 0; idx-- { | |
| 6958 | + | message := messages[idx] | |
| 6959 | + | if message.Role == provider.RoleUser && message.Meta[autoCompactContinueMetaKey] == "true" { | |
| 6960 | + | lastHandoff = idx | |
| 6961 | + | break | |
| 6962 | + | } | |
| 6963 | + | } | |
| 6964 | + | if lastHandoff < 0 { | |
| 6965 | + | return compactHandoffAppendPrompt | |
| 6966 | + | } | |
| 6967 | + | for _, message := range messages[lastHandoff+1:] { | |
| 6968 | + | switch message.Role { | |
| 6969 | + | case provider.RoleTool: | |
| 6970 | + | continue | |
| 6971 | + | case provider.RoleAssistant: | |
| 6972 | + | hasToolCall := false | |
| 6973 | + | for _, content := range message.Content { | |
| 6974 | + | if _, ok := content.(provider.ToolCallBlock); ok { | |
| 6975 | + | hasToolCall = true | |
| 6976 | + | break | |
| 6977 | + | } | |
| 6978 | + | } | |
| 6979 | + | if !hasToolCall { | |
| 6980 | + | return compactHandoffDiscard | |
| 6981 | + | } | |
| 6982 | + | default: | |
| 6983 | + | return compactHandoffDiscard | |
| 6984 | + | } | |
| 6985 | + | } | |
| 6986 | + | return compactHandoffContinueExisting | |
| 6987 | + | } | |
| 6988 | + | ||
| 6989 | + | // startRestoredCompactHandoff resumes a handoff checkpoint restored from the | |
| 6990 | + | // active session. When its hidden context message was already persisted, the | |
| 6991 | + | // agent continues that existing turn rather than appending it again. | |
| 6992 | + | func (i *Interactive) startRestoredCompactHandoff(parent context.Context) { | |
| 6993 | + | if parent == nil { | |
| 6994 | + | parent = i.runCtx | |
| 6995 | + | } | |
| 6996 | + | if parent == nil { | |
| 6997 | + | parent = context.Background() | |
| 6998 | + | } | |
| 6999 | + | i.mu.Lock() | |
| 7000 | + | state := i.compactContinuation | |
| 7001 | + | ag := i.agent | |
| 7002 | + | busy := i.busy || i.compacting || i.autoCompacting || i.shellRunning | |
| 7003 | + | if state.reason == compactContinuationNone || ag == nil || busy { | |
| 7004 | + | i.mu.Unlock() | |
| 7005 | + | return | |
| 7006 | + | } | |
| 7007 | + | ||
| 7008 | + | var next string | |
| 7009 | + | continueQueued := false | |
| 7010 | + | var resume compactHandoffResume | |
| 7011 | + | var handoff json.RawMessage | |
| 7012 | + | var persistHandoff bool | |
| 7013 | + | switch { | |
| 7014 | + | case state.reason != compactContinuationForcedLength && len(i.queued) > 0: | |
| 7015 | + | next, i.queued = i.queued[0], i.queued[1:] | |
| 7016 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 7017 | + | case state.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0: | |
| 7018 | + | continueQueued = true | |
| 7019 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 7020 | + | default: | |
| 7021 | + | resume = classifyCompactHandoffResume(ag.Messages()) | |
| 7022 | + | if resume == compactHandoffDiscard { | |
| 7023 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 7024 | + | } | |
| 7025 | + | } | |
| 7026 | + | i.mu.Unlock() | |
| 7027 | + | if persistHandoff { | |
| 7028 | + | i.persistCompactHandoff(handoff) | |
| 7029 | + | } | |
| 7030 | + | switch { | |
| 7031 | + | case next != "": | |
| 7032 | + | i.startTurn(parent, next) | |
| 7033 | + | case continueQueued: | |
| 7034 | + | i.startTurnRequest(parent, "", nil, true, false) | |
| 7035 | + | case resume == compactHandoffContinueExisting: | |
| 7036 | + | i.startTurnRequest(parent, "", nil, true, false) | |
| 7037 | + | case resume == compactHandoffAppendPrompt: | |
| 7038 | + | i.startAutoCompactContinuation(parent) | |
| 7039 | + | } | |
| 7040 | + | } | |
| 7041 | + | ||
| 6759 | 7042 | // startAutoCompactContinuation adds an internal user turn that tells the | |
| 6760 | 7043 | // model to resume the task after threshold compaction. Provider requests | |
| 6761 | 7044 | // cannot continue from an assistant tail on every supported API, so the | |
| @@ -6779,14 +7062,20 @@ func (i *Interactive) startAutoCompactContinuation(parent context.Context) { | |||
| 6779 | 7062 | if i.compactContinuation.reason != compactContinuationForcedLength && len(i.queued) > 0 { | |
| 6780 | 7063 | next := i.queued[0] | |
| 6781 | 7064 | i.queued = i.queued[1:] | |
| 6782 | - | i.resetCompactContinuationLocked() | |
| 7065 | + | handoff, persistHandoff := i.resetCompactContinuationLocked() | |
| 6783 | 7066 | i.mu.Unlock() | |
| 7067 | + | if persistHandoff { | |
| 7068 | + | i.persistCompactHandoff(handoff) | |
| 7069 | + | } | |
| 6784 | 7070 | i.startTurn(parent, next) | |
| 6785 | 7071 | return | |
| 6786 | 7072 | } | |
| 6787 | 7073 | if i.compactContinuation.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0 { | |
| 6788 | - | i.resetCompactContinuationLocked() | |
| 7074 | + | handoff, persistHandoff := i.resetCompactContinuationLocked() | |
| 6789 | 7075 | i.mu.Unlock() | |
| 7076 | + | if persistHandoff { | |
| 7077 | + | i.persistCompactHandoff(handoff) | |
| 7078 | + | } | |
| 6790 | 7079 | i.startTurnRequest(parent, "", nil, true, false) | |
| 6791 | 7080 | return | |
| 6792 | 7081 | } | |
| @@ -6827,8 +7116,10 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||
| 6827 | 7116 | i.activity.kind = activityCompactingHistory | |
| 6828 | 7117 | } | |
| 6829 | 7118 | i.cancelTurn = cancel | |
| 7119 | + | var initialHandoff json.RawMessage | |
| 7120 | + | persistInitialHandoff := false | |
| 6830 | 7121 | if request.origin == compactOriginManual || request.origin == compactOriginRecovery { | |
| 6831 | - | i.resetCompactContinuationLocked() | |
| 7122 | + | initialHandoff, persistInitialHandoff = i.resetCompactContinuationLocked() | |
| 6832 | 7123 | } | |
| 6833 | 7124 | i.statusErr = "" | |
| 6834 | 7125 | i.statusOK = "" | |
| @@ -6838,6 +7129,9 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||
| 6838 | 7129 | i.scrollOffset = 0 | |
| 6839 | 7130 | i.helpBlock = nil | |
| 6840 | 7131 | i.mu.Unlock() | |
| 7132 | + | if persistInitialHandoff { | |
| 7133 | + | i.persistCompactHandoff(initialHandoff) | |
| 7134 | + | } | |
| 6841 | 7135 | i.invalidate() | |
| 6842 | 7136 | ||
| 6843 | 7137 | go func() { | |
| @@ -6880,6 +7174,8 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||
| 6880 | 7174 | var hasNext bool | |
| 6881 | 7175 | var continueExisting bool | |
| 6882 | 7176 | var continueAutomatically bool | |
| 7177 | + | var handoff json.RawMessage | |
| 7178 | + | var persistHandoff bool | |
| 6883 | 7179 | ||
| 6884 | 7180 | switch { | |
| 6885 | 7181 | case err != nil && ctx.Err() != nil: | |
| @@ -6891,7 +7187,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||
| 6891 | 7187 | } | |
| 6892 | 7188 | i.queued = nil // drop queue on cancel | |
| 6893 | 7189 | i.clearPendingCompactTurnLocked() | |
| 6894 | - | i.resetCompactContinuationLocked() | |
| 7190 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 6895 | 7191 | if i.agent != nil { | |
| 6896 | 7192 | i.agent.DrainQueuedMessages() | |
| 6897 | 7193 | } | |
| @@ -6900,7 +7196,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||
| 6900 | 7196 | i.statusOK = "" | |
| 6901 | 7197 | i.queued = nil // drop queue on error | |
| 6902 | 7198 | i.clearPendingCompactTurnLocked() | |
| 6903 | - | i.resetCompactContinuationLocked() | |
| 7199 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 6904 | 7200 | if i.agent != nil { | |
| 6905 | 7201 | i.agent.DrainQueuedMessages() | |
| 6906 | 7202 | } | |
| @@ -6941,12 +7237,12 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||
| 6941 | 7237 | case continuationReason == compactContinuationForcedLength: | |
| 6942 | 7238 | // Forced truncated-output continuation keeps its existing | |
| 6943 | 7239 | // priority over an explicitly queued prompt. | |
| 6944 | - | i.compactContinuation = compactContinuationState{reason: continuationReason} | |
| 7240 | + | handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason}) | |
| 6945 | 7241 | continueAutomatically = true | |
| 6946 | 7242 | case len(i.queued) > 0: | |
| 6947 | 7243 | next, i.queued = i.queued[0], i.queued[1:] | |
| 6948 | 7244 | hasNext = true | |
| 6949 | - | i.resetCompactContinuationLocked() | |
| 7245 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 6950 | 7246 | case continuationReason == compactContinuationStructuralTail || continuationReason == compactContinuationStatusRescue: | |
| 6951 | 7247 | if continuationReason == compactContinuationStatusRescue { | |
| 6952 | 7248 | attempts := 1 | |
| @@ -6954,19 +7250,19 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||
| 6954 | 7250 | attempts = i.compactContinuation.rescueAttempts + 1 | |
| 6955 | 7251 | } | |
| 6956 | 7252 | if attempts > maxStatusRescueContinuations { | |
| 6957 | - | i.resetCompactContinuationLocked() | |
| 7253 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 6958 | 7254 | break | |
| 6959 | 7255 | } | |
| 6960 | - | i.compactContinuation = compactContinuationState{ | |
| 7256 | + | handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{ | |
| 6961 | 7257 | reason: continuationReason, | |
| 6962 | 7258 | rescueAttempts: attempts, | |
| 6963 | - | } | |
| 7259 | + | }) | |
| 6964 | 7260 | } else { | |
| 6965 | - | i.compactContinuation = compactContinuationState{reason: continuationReason} | |
| 7261 | + | handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason}) | |
| 6966 | 7262 | } | |
| 6967 | 7263 | continueAutomatically = true | |
| 6968 | 7264 | default: | |
| 6969 | - | i.resetCompactContinuationLocked() | |
| 7265 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 6970 | 7266 | } | |
| 6971 | 7267 | } | |
| 6972 | 7268 | // Keep the host busy until the hand-off decision is committed under | |
| @@ -6977,6 +7273,9 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||
| 6977 | 7273 | i.compacting = false | |
| 6978 | 7274 | i.autoCompacting = false | |
| 6979 | 7275 | i.mu.Unlock() | |
| 7276 | + | if persistHandoff { | |
| 7277 | + | i.persistCompactHandoff(handoff) | |
| 7278 | + | } | |
| 6980 | 7279 | runPendingIdleWork(pendingIdleWork) | |
| 6981 | 7280 | i.invalidate() | |
| 6982 | 7281 | ||
| @@ -7156,8 +7455,10 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 7156 | 7455 | // The condense flow re-fires the user's queued prompt for us, so | |
| 7157 | 7456 | // we just hand it off and exit. | |
| 7158 | 7457 | i.mu.Lock() | |
| 7458 | + | var resetHandoff json.RawMessage | |
| 7459 | + | var persistResetHandoff bool | |
| 7159 | 7460 | if !continueExisting { | |
| 7160 | - | i.resetCompactContinuationLocked() | |
| 7461 | + | resetHandoff, persistResetHandoff = i.resetCompactContinuationLocked() | |
| 7161 | 7462 | } | |
| 7162 | 7463 | needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked() | |
| 7163 | 7464 | if needsPreCompact { | |
| @@ -7178,11 +7479,17 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 7178 | 7479 | i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending...")) | |
| 7179 | 7480 | i.pendingPostCompactNote = "context auto-compacted; sending your last message" | |
| 7180 | 7481 | i.mu.Unlock() | |
| 7482 | + | if persistResetHandoff { | |
| 7483 | + | i.persistCompactHandoff(resetHandoff) | |
| 7484 | + | } | |
| 7181 | 7485 | i.invalidate() | |
| 7182 | 7486 | i.runCompact(parent, compactContinuationRequest{origin: compactOriginPreTurnThreshold}) | |
| 7183 | 7487 | return | |
| 7184 | 7488 | } | |
| 7185 | 7489 | i.mu.Unlock() | |
| 7490 | + | if persistResetHandoff { | |
| 7491 | + | i.persistCompactHandoff(resetHandoff) | |
| 7492 | + | } | |
| 7186 | 7493 | ||
| 7187 | 7494 | ctx, cancel := context.WithCancel(parent) | |
| 7188 | 7495 | i.mu.Lock() | |
| @@ -7349,18 +7656,23 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 7349 | 7656 | continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil | |
| 7350 | 7657 | shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked() | |
| 7351 | 7658 | continueStatusRescue := false | |
| 7659 | + | var handoff json.RawMessage | |
| 7660 | + | var persistHandoff bool | |
| 7352 | 7661 | if statusRescueActive && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil { | |
| 7353 | 7662 | followUpMessages := i.agent.Messages() | |
| 7354 | 7663 | reason := classifyCompactionContinuation(compactOriginManual, true, lastStop, lastTurnErr, followUpMessages) | |
| 7355 | 7664 | if reason == compactContinuationStatusRescue && i.compactContinuation.rescueAttempts < maxStatusRescueContinuations { | |
| 7356 | - | i.compactContinuation.rescueAttempts++ | |
| 7665 | + | handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{ | |
| 7666 | + | reason: compactContinuationStatusRescue, | |
| 7667 | + | rescueAttempts: i.compactContinuation.rescueAttempts + 1, | |
| 7668 | + | }) | |
| 7357 | 7669 | continueStatusRescue = true | |
| 7358 | 7670 | } else { | |
| 7359 | - | i.resetCompactContinuationLocked() | |
| 7671 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 7360 | 7672 | } | |
| 7361 | 7673 | } | |
| 7362 | 7674 | if !continueStatusRescue && (ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) { | |
| 7363 | - | i.resetCompactContinuationLocked() | |
| 7675 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 7364 | 7676 | } | |
| 7365 | 7677 | // The agent run can finish before the paced final text reaches the | |
| 7366 | 7678 | // transcript. A compaction replaces that transcript, so it must never | |
| @@ -7372,6 +7684,9 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 7372 | 7684 | alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact) | |
| 7373 | 7685 | i.busy = hasNext || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact | |
| 7374 | 7686 | i.mu.Unlock() | |
| 7687 | + | if persistHandoff { | |
| 7688 | + | i.persistCompactHandoff(handoff) | |
| 7689 | + | } | |
| 7375 | 7690 | if alertReason != "" { | |
| 7376 | 7691 | i.scheduleMainAlert(alertReason) | |
| 7377 | 7692 | } | |
| @@ -7866,7 +8181,6 @@ func (i *Interactive) applyAgentPromptConfig(ag *core.Agent, system string, tool | |||
| 7866 | 8181 | // prepareReplacementAgentLocked applies session-wide capability policy before | |
| 7867 | 8182 | // ag becomes visible to prompt submission. The caller holds agentMu and i.mu. | |
| 7868 | 8183 | func (i *Interactive) prepareReplacementAgentLocked(ag *core.Agent) { | |
| 7869 | - | i.resetCompactContinuationLocked() | |
| 7870 | 8184 | if ag == nil { | |
| 7871 | 8185 | i.setWebSearchAvailable(false) | |
| 7872 | 8186 | return | |
| @@ -8778,11 +9092,27 @@ func (i *Interactive) doSessionImport(src string) { | |||
| 8778 | 9092 | return | |
| 8779 | 9093 | } | |
| 8780 | 9094 | i.restoreLoadedSessionTitle() | |
| 9095 | + | state := i.restoreCurrentCompactHandoff() | |
| 8781 | 9096 | i.mu.Lock() | |
| 8782 | 9097 | i.statusOK = "imported and switched to session " + friendlyPath(newPath) | |
| 8783 | 9098 | i.statusErr = "" | |
| 9099 | + | if i.agent != nil { | |
| 9100 | + | i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages()) | |
| 9101 | + | i.cumUsage = i.agent.Cost() | |
| 9102 | + | last := i.agent.LastTurnUsage() | |
| 9103 | + | i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens | |
| 9104 | + | if len(i.view.Messages) > initialResumeTailLimit { | |
| 9105 | + | i.view.TailLimit = initialResumeTailLimit | |
| 9106 | + | } else { | |
| 9107 | + | i.view.TailLimit = 0 | |
| 9108 | + | } | |
| 9109 | + | i.view.InvalidateRenderCache() | |
| 9110 | + | } | |
| 8784 | 9111 | i.mu.Unlock() | |
| 8785 | 9112 | i.invalidate() | |
| 9113 | + | if state.reason != compactContinuationNone { | |
| 9114 | + | i.startRestoredCompactHandoff(i.runCtx) | |
| 9115 | + | } | |
| 8786 | 9116 | } | |
| 8787 | 9117 | ||
| 8788 | 9118 | // defaultExportDir returns ~/Downloads when it exists, or ~ as a | |
| @@ -8997,6 +9327,7 @@ func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo in | |||
| 8997 | 9327 | i.setSessionTreeError("tree: checkout failed: " + err.Error()) | |
| 8998 | 9328 | return | |
| 8999 | 9329 | } | |
| 9330 | + | state := i.restoreCurrentCompactHandoff() | |
| 9000 | 9331 | ||
| 9001 | 9332 | // LoadSession is intentionally the old func(string) error callback: the | |
| 9002 | 9333 | // CLI and embedders already own agent/session swapping. Refresh the view | |
| @@ -9032,6 +9363,9 @@ func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo in | |||
| 9032 | 9363 | // Submit, startTurn, or queue a message here; the restored user draft is | |
| 9033 | 9364 | // submitted only by a later explicit Enter. | |
| 9034 | 9365 | i.invalidate() | |
| 9366 | + | if state.reason != compactContinuationNone { | |
| 9367 | + | i.startRestoredCompactHandoff(i.runCtx) | |
| 9368 | + | } | |
| 9035 | 9369 | } | |
| 9036 | 9370 | ||
| 9037 | 9371 | // applyForkSelection branches the current session at msgIdx+1 (so | |
| @@ -9079,12 +9413,16 @@ func (i *Interactive) applyForkSelection(msgIdx int) { | |||
| 9079 | 9413 | i.invalidate() | |
| 9080 | 9414 | return | |
| 9081 | 9415 | } | |
| 9416 | + | state := i.restoreCurrentCompactHandoff() | |
| 9082 | 9417 | i.resetSessionTitleForFreshBranch() | |
| 9083 | 9418 | i.mu.Lock() | |
| 9084 | 9419 | i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath) | |
| 9085 | 9420 | i.statusErr = "" | |
| 9086 | 9421 | i.mu.Unlock() | |
| 9087 | 9422 | i.invalidate() | |
| 9423 | + | if state.reason != compactContinuationNone { | |
| 9424 | + | i.startRestoredCompactHandoff(i.runCtx) | |
| 9425 | + | } | |
| 9088 | 9426 | } | |
| 9089 | 9427 | ||
| 9090 | 9428 | // 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.Agent | 189 | 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 string | 201 | InitialInput string | ||
| 192 | 202 | ||||
| 193 | // StartupPre is auto-submitted once when the interactive session | 203 | // StartupPre is auto-submitted once when the interactive session | ||
| @@ -526,11 +536,64 @@ const ( | |||||
| 526 | 536 | ||||
| 527 | const maxStatusRescueContinuations = 2 | 537 | const maxStatusRescueContinuations = 2 | ||
| 528 | 538 | ||||
| 539 | + | const compactHandoffVersion = 1 | |||
| 540 | + | ||||
| 529 | type compactContinuationState struct { | 541 | type compactContinuationState struct { | ||
| 530 | reason compactContinuationReason | 542 | reason compactContinuationReason | ||
| 531 | rescueAttempts int | 543 | 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 InteractiveConfig | 598 | cfg InteractiveConfig | ||
| 536 | view *tui.View | 599 | view *tui.View | ||
| @@ -850,38 +913,39 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||||
| 850 | // Prompt is the standard half-block accent bar used by chat | 913 | // Prompt is the standard half-block accent bar used by chat | ||
| 851 | // speaker labels too, so the input gutter matches the rest | 914 | // 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.Agent | 963 | 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 persisted | 966 | // Rehydrate the "context used" gauge from the last persisted | ||
| 903 | // turn. Without this the status bar reads 0.0% after a resume | 967 | // 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 the | 1066 | // Stamp the welcome time and schedule a one-shot redraw at the | ||
| @@ -3086,10 +3153,15 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 3086 | i.mu.Lock() | 3153 | i.mu.Lock() | ||
| 3087 | busyCancel := i.busy && i.cancelTurn != nil | 3154 | busyCancel := i.busy && i.cancelTurn != nil | ||
| 3088 | cancelTurn := i.cancelTurn | 3155 | cancelTurn := i.cancelTurn | ||
| 3156 | + | var handoff json.RawMessage | |||
| 3157 | + | var persistHandoff bool | |||
| 3089 | if busyCancel { | 3158 | if busyCancel { | ||
| 3090 | - | i.resetCompactContinuationLocked() | 3159 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() |
| 3091 | } | 3160 | } | ||
| 3092 | i.mu.Unlock() | 3161 | i.mu.Unlock() | ||
| 3162 | + | if persistHandoff { | |||
| 3163 | + | i.persistCompactHandoff(handoff) | |||
| 3164 | + | } | |||
| 3093 | if busyCancel { | 3165 | if busyCancel { | ||
| 3094 | cancelTurn() | 3166 | cancelTurn() | ||
| 3095 | // If a confirm dialog is pending, refuse it so the agent | 3167 | // If a confirm dialog is pending, refuse it so the agent | ||
| @@ -3403,17 +3475,22 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 3403 | return false | 3475 | 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 false | 3495 | 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 startup | 3832 | // 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 | return | 3840 | 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 = ag | 3847 | i.agent = ag | ||
| 3766 | i.cfg.Provider = providerName | 3848 | i.cfg.Provider = providerName | ||
| 3767 | i.cfg.Model = model | 3849 | 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 = ag | 3905 | i.agent = ag | ||
| 3823 | i.cfg.CWD = cwd | 3906 | i.cfg.CWD = cwd | ||
| 3824 | i.cfg.SubagentsSystemAddendum = subagentsAddendum | 3907 | 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 loop | 3997 | // 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 | return | 4013 | return | ||
| 3926 | } | 4014 | } | ||
| @@ -3946,8 +4034,11 @@ func (i *Interactive) CancelTurn() { | |||||
| 3946 | i.mu.Unlock() | 4034 | i.mu.Unlock() | ||
| 3947 | if cancel != nil { | 4035 | if cancel != nil { | ||
| 3948 | i.mu.Lock() | 4036 | i.mu.Lock() | ||
| 3949 | - | i.resetCompactContinuationLocked() | 4037 | + | handoff, persistHandoff := i.resetCompactContinuationLocked() |
| 3950 | i.mu.Unlock() | 4038 | i.mu.Unlock() | ||
| 4039 | + | if persistHandoff { | |||
| 4040 | + | i.persistCompactHandoff(handoff) | |||
| 4041 | + | } | |||
| 3951 | cancel() | 4042 | cancel() | ||
| 3952 | i.confirmDialog.CancelAll("turn cancelled") | 4043 | i.confirmDialog.CancelAll("turn cancelled") | ||
| 3953 | } | 4044 | } | ||
| @@ -5712,9 +5803,12 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 5712 | i.scrollOffset = 0 | 5803 | i.scrollOffset = 0 | ||
| 5713 | i.extNotes = nil | 5804 | i.extNotes = nil | ||
| 5714 | i.reloadErrors = nil | 5805 | 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.agent | 5905 | 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 | break | 5925 | 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 = llamaConfigured | 6206 | 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 | return | 6210 | 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. Drop | 6216 | // 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 at | 6217 | // 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 = nil | 6222 | i.agent = nil | ||
| 6122 | - | i.resetCompactContinuationLocked() | 6223 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() |
| 6123 | i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in." | 6224 | i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in." | ||
| 6124 | } else { | 6225 | } else { | ||
| 6125 | i.statusOK = "logged out of " + strings.Join(providers, ", ") | 6226 | i.statusOK = "logged out of " + strings.Join(providers, ", ") | ||
| 6126 | } | 6227 | } | ||
| 6228 | + | i.mu.Unlock() | |||
| 6229 | + | if persistHandoff { | |||
| 6230 | + | i.persistCompactHandoff(handoff) | |||
| 6231 | + | } | |||
| 6127 | } | 6232 | } | ||
| 6128 | 6233 | ||||
| 6129 | func providerSetupInfo(provider string) (string, []string, bool) { | 6234 | func providerSetupInfo(provider string) (string, []string, bool) { | ||
| @@ -6306,8 +6411,11 @@ func (i *Interactive) cancelAndWaitForIdle() { | |||||
| 6306 | } | 6411 | } | ||
| 6307 | if cancel != nil { | 6412 | if cancel != nil { | ||
| 6308 | i.mu.Lock() | 6413 | i.mu.Lock() | ||
| 6309 | - | i.resetCompactContinuationLocked() | 6414 | + | handoff, persistHandoff := i.resetCompactContinuationLocked() |
| 6310 | i.mu.Unlock() | 6415 | i.mu.Unlock() | ||
| 6416 | + | if persistHandoff { | |||
| 6417 | + | i.persistCompactHandoff(handoff) | |||
| 6418 | + | } | |||
| 6311 | cancel() | 6419 | cancel() | ||
| 6312 | } | 6420 | } | ||
| 6313 | // ConfirmToolCall waits on its response channel rather than the turn | 6421 | // ConfirmToolCall waits on its response channel rather than the turn | ||
| @@ -6371,14 +6479,19 @@ func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) { | |||||
| 6371 | // Keep the mutex held while enqueueing so the turn-completion | 6479 | // Keep the mutex held while enqueueing so the turn-completion | ||
| 6372 | // goroutine cannot publish idle and inspect an empty queue between | 6480 | // 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 | return | 6496 | return | ||
| 6384 | } | 6497 | } | ||
| @@ -6525,7 +6638,9 @@ func (i *Interactive) applySessionSelection(path string) { | |||||
| 6525 | return | 6638 | 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 = false | 6644 | i.sessionLoading = false | ||
| 6530 | i.statusOK = "resumed session: " + path | 6645 | i.statusOK = "resumed session: " + path | ||
| 6531 | i.statusErr = "" | 6646 | i.statusErr = "" | ||
| @@ -6540,7 +6655,7 @@ func (i *Interactive) applySessionSelection(path string) { | |||||
| 6540 | i.extNotes = nil | 6655 | 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.CacheWriteTokens | 6661 | 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 the | 6699 | // 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 | - | return | 6710 | + | } |
| 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 | - | return | 6718 | + | 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 | - | return | 6725 | + | return false |
| 6603 | } | 6726 | } | ||
| 6604 | // Same provider AND not a rescue retry: just swap the model on | 6727 | // 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 | - | return | 6741 | + | 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 | - | return | 6747 | + | return false |
| 6625 | } | 6748 | } | ||
| 6626 | // Snapshot the current transcript and cumulative usage BEFORE we | 6749 | // Snapshot the current transcript and cumulative usage BEFORE we | ||
| 6627 | // build the replacement agent so we can hand them off. Without | 6750 | // 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 | - | return | 6765 | + | return false |
| 6643 | } | 6766 | } | ||
| 6644 | 6767 | ||||
| 6645 | // Replay the transcript and seed the cost on the freshly-built | 6768 | // 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 that | 6821 | // 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 error | 6823 | 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.agent | 6833 | oldAgent := i.agent | ||
| 6709 | i.prepareReplacementAgentLocked(ag) | 6834 | i.prepareReplacementAgentLocked(ag) | ||
| 6835 | + | _, clearHandoff = i.resetCompactContinuationLocked() | |||
| 6710 | i.agent = ag | 6836 | i.agent = ag | ||
| 6711 | i.cfg.Provider = prov | 6837 | i.cfg.Provider = prov | ||
| 6712 | i.cfg.Model = model | 6838 | 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 | return | 6861 | return | ||
| 6736 | } | 6862 | } | ||
| 6863 | + | if clearHandoff { | |||
| 6864 | + | i.persistCompactHandoff(nil) | |||
| 6865 | + | } | |||
| 6737 | i.dialog.ShowResult(true, "") | 6866 | i.dialog.ShowResult(true, "") | ||
| 6738 | } | 6867 | } | ||
| 6739 | } | 6868 | } | ||
| @@ -6748,14 +6877,168 @@ func (i *Interactive) clearPendingCompactTurnLocked() { | |||||
| 6748 | i.pendingPostCompactNote = "" | 6877 | i.pendingPostCompactNote = "" | ||
| 6749 | } | 6878 | } | ||
| 6750 | 6879 | ||||
| 6751 | - | // resetCompactContinuationLocked clears the private handoff state. The | 6880 | + | // compactHandoffLocked returns a self-contained persistence snapshot. The |
| 6752 | // caller must hold i.mu. | 6881 | // caller must hold i.mu. | ||
| 6753 | - | func (i *Interactive) resetCompactContinuationLocked() { | 6882 | + | func (i *Interactive) compactHandoffLocked() json.RawMessage { |
| 6754 | - | i.compactContinuation = compactContinuationState{} | 6883 | + | return encodeCompactHandoff(i.compactContinuation) |
| 6884 | + | } | |||
| 6885 | + | ||||
| 6886 | + | // setCompactContinuationLocked updates the private handoff state and returns | |||
| 6887 | + | // its persistence snapshot plus whether persistence is needed. The caller must | |||
| 6888 | + | // hold i.mu. | |||
| 6889 | + | func (i *Interactive) setCompactContinuationLocked(state compactContinuationState) (json.RawMessage, bool) { | |||
| 6890 | + | if i.compactContinuation == state { | |||
| 6891 | + | return i.compactHandoffLocked(), false | |||
| 6892 | + | } | |||
| 6893 | + | i.compactContinuation = state | |||
| 6894 | + | return i.compactHandoffLocked(), true | |||
| 6895 | + | } | |||
| 6896 | + | ||||
| 6897 | + | // resetCompactContinuationLocked clears the private handoff state and returns | |||
| 6898 | + | // its persistence snapshot plus whether persistence is needed. The caller must | |||
| 6899 | + | // hold i.mu. | |||
| 6900 | + | func (i *Interactive) resetCompactContinuationLocked() (json.RawMessage, bool) { | |||
| 6901 | + | return i.setCompactContinuationLocked(compactContinuationState{}) | |||
| 6902 | + | } | |||
| 6903 | + | ||||
| 6904 | + | func (i *Interactive) persistCompactHandoff(state json.RawMessage) { | |||
| 6905 | + | if persist := i.cfg.PersistCompactHandoff; persist != nil { | |||
| 6906 | + | if err := persist(state); err != nil { | |||
| 6907 | + | i.ReportError(fmt.Errorf("persist compact handoff: %w", err)) | |||
| 6908 | + | } | |||
| 6909 | + | } | |||
| 6910 | + | } | |||
| 6911 | + | ||||
| 6912 | + | func (i *Interactive) resetCompactHandoff() { | |||
| 6913 | + | i.mu.Lock() | |||
| 6914 | + | state, persist := i.resetCompactContinuationLocked() | |||
| 6915 | + | i.mu.Unlock() | |||
| 6916 | + | if persist { | |||
| 6917 | + | i.persistCompactHandoff(state) | |||
| 6918 | + | } | |||
| 6919 | + | } | |||
| 6920 | + | ||||
| 6921 | + | func (i *Interactive) currentCompactHandoff() json.RawMessage { | |||
| 6922 | + | if current := i.cfg.CurrentCompactHandoff; current != nil { | |||
| 6923 | + | return current() | |||
| 6924 | + | } | |||
| 6925 | + | return nil | |||
| 6926 | + | } | |||
| 6927 | + | ||||
| 6928 | + | func (i *Interactive) restoreCompactHandoff(state compactContinuationState) { | |||
| 6929 | + | i.mu.Lock() | |||
| 6930 | + | i.compactContinuation = state | |||
| 6931 | + | i.mu.Unlock() | |||
| 6932 | + | } | |||
| 6933 | + | ||||
| 6934 | + | func (i *Interactive) restoreCurrentCompactHandoff() compactContinuationState { | |||
| 6935 | + | state := decodeCompactHandoff(i.currentCompactHandoff()) | |||
| 6936 | + | i.restoreCompactHandoff(state) | |||
| 6937 | + | return state | |||
| 6755 | } | 6938 | } | ||
| 6756 | 6939 | ||||
| 6757 | const autoCompactContinuationPrompt = `Context was compacted while work was in progress. Continue the user's most recent active request now; do not wait for them to type "continue". Inspect the context summary and kept recent messages, treating active constraints and preferences there as still in force, including delegation/subagent instructions. If a newer request supersedes earlier plans in the summary, follow the newer request. A progress report, plan update, or statement of future work is not completion: if required work remains, take the next concrete action now. Only finish when the request is actually complete or when a specific user decision is required. If nothing remains, give a brief truthful completion; do not invent work or force a tool call.` | 6940 | const autoCompactContinuationPrompt = `Context was compacted while work was in progress. Continue the user's most recent active request now; do not wait for them to type "continue". Inspect the context summary and kept recent messages, treating active constraints and preferences there as still in force, including delegation/subagent instructions. If a newer request supersedes earlier plans in the summary, follow the newer request. A progress report, plan update, or statement of future work is not completion: if required work remains, take the next concrete action now. Only finish when the request is actually complete or when a specific user decision is required. If nothing remains, give a brief truthful completion; do not invent work or force a tool call.` | ||
| 6758 | 6941 | ||||
| 6942 | + | type compactHandoffResume uint8 | |||
| 6943 | + | ||||
| 6944 | + | const ( | |||
| 6945 | + | compactHandoffDiscard compactHandoffResume = iota | |||
| 6946 | + | compactHandoffContinueExisting | |||
| 6947 | + | compactHandoffAppendPrompt | |||
| 6948 | + | ) | |||
| 6949 | + | ||||
| 6950 | + | // classifyCompactHandoffResume validates that the persisted checkpoint still | |||
| 6951 | + | // describes the effective transcript. A normal assistant reply or a newer | |||
| 6952 | + | // explicit user message supersedes the checkpoint; tool activity remains an | |||
| 6953 | + | // unfinished existing turn. No hidden prompt means the process stopped after | |||
| 6954 | + | // recording the handoff but before appending its context message. | |||
| 6955 | + | func classifyCompactHandoffResume(messages []provider.Message) compactHandoffResume { | |||
| 6956 | + | lastHandoff := -1 | |||
| 6957 | + | for idx := len(messages) - 1; idx >= 0; idx-- { | |||
| 6958 | + | message := messages[idx] | |||
| 6959 | + | if message.Role == provider.RoleUser && message.Meta[autoCompactContinueMetaKey] == "true" { | |||
| 6960 | + | lastHandoff = idx | |||
| 6961 | + | break | |||
| 6962 | + | } | |||
| 6963 | + | } | |||
| 6964 | + | if lastHandoff < 0 { | |||
| 6965 | + | return compactHandoffAppendPrompt | |||
| 6966 | + | } | |||
| 6967 | + | for _, message := range messages[lastHandoff+1:] { | |||
| 6968 | + | switch message.Role { | |||
| 6969 | + | case provider.RoleTool: | |||
| 6970 | + | continue | |||
| 6971 | + | case provider.RoleAssistant: | |||
| 6972 | + | hasToolCall := false | |||
| 6973 | + | for _, content := range message.Content { | |||
| 6974 | + | if _, ok := content.(provider.ToolCallBlock); ok { | |||
| 6975 | + | hasToolCall = true | |||
| 6976 | + | break | |||
| 6977 | + | } | |||
| 6978 | + | } | |||
| 6979 | + | if !hasToolCall { | |||
| 6980 | + | return compactHandoffDiscard | |||
| 6981 | + | } | |||
| 6982 | + | default: | |||
| 6983 | + | return compactHandoffDiscard | |||
| 6984 | + | } | |||
| 6985 | + | } | |||
| 6986 | + | return compactHandoffContinueExisting | |||
| 6987 | + | } | |||
| 6988 | + | ||||
| 6989 | + | // startRestoredCompactHandoff resumes a handoff checkpoint restored from the | |||
| 6990 | + | // active session. When its hidden context message was already persisted, the | |||
| 6991 | + | // agent continues that existing turn rather than appending it again. | |||
| 6992 | + | func (i *Interactive) startRestoredCompactHandoff(parent context.Context) { | |||
| 6993 | + | if parent == nil { | |||
| 6994 | + | parent = i.runCtx | |||
| 6995 | + | } | |||
| 6996 | + | if parent == nil { | |||
| 6997 | + | parent = context.Background() | |||
| 6998 | + | } | |||
| 6999 | + | i.mu.Lock() | |||
| 7000 | + | state := i.compactContinuation | |||
| 7001 | + | ag := i.agent | |||
| 7002 | + | busy := i.busy || i.compacting || i.autoCompacting || i.shellRunning | |||
| 7003 | + | if state.reason == compactContinuationNone || ag == nil || busy { | |||
| 7004 | + | i.mu.Unlock() | |||
| 7005 | + | return | |||
| 7006 | + | } | |||
| 7007 | + | ||||
| 7008 | + | var next string | |||
| 7009 | + | continueQueued := false | |||
| 7010 | + | var resume compactHandoffResume | |||
| 7011 | + | var handoff json.RawMessage | |||
| 7012 | + | var persistHandoff bool | |||
| 7013 | + | switch { | |||
| 7014 | + | case state.reason != compactContinuationForcedLength && len(i.queued) > 0: | |||
| 7015 | + | next, i.queued = i.queued[0], i.queued[1:] | |||
| 7016 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |||
| 7017 | + | case state.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0: | |||
| 7018 | + | continueQueued = true | |||
| 7019 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |||
| 7020 | + | default: | |||
| 7021 | + | resume = classifyCompactHandoffResume(ag.Messages()) | |||
| 7022 | + | if resume == compactHandoffDiscard { | |||
| 7023 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |||
| 7024 | + | } | |||
| 7025 | + | } | |||
| 7026 | + | i.mu.Unlock() | |||
| 7027 | + | if persistHandoff { | |||
| 7028 | + | i.persistCompactHandoff(handoff) | |||
| 7029 | + | } | |||
| 7030 | + | switch { | |||
| 7031 | + | case next != "": | |||
| 7032 | + | i.startTurn(parent, next) | |||
| 7033 | + | case continueQueued: | |||
| 7034 | + | i.startTurnRequest(parent, "", nil, true, false) | |||
| 7035 | + | case resume == compactHandoffContinueExisting: | |||
| 7036 | + | i.startTurnRequest(parent, "", nil, true, false) | |||
| 7037 | + | case resume == compactHandoffAppendPrompt: | |||
| 7038 | + | i.startAutoCompactContinuation(parent) | |||
| 7039 | + | } | |||
| 7040 | + | } | |||
| 7041 | + | ||||
| 6759 | // startAutoCompactContinuation adds an internal user turn that tells the | 7042 | // startAutoCompactContinuation adds an internal user turn that tells the | ||
| 6760 | // model to resume the task after threshold compaction. Provider requests | 7043 | // model to resume the task after threshold compaction. Provider requests | ||
| 6761 | // cannot continue from an assistant tail on every supported API, so the | 7044 | // cannot continue from an assistant tail on every supported API, so the | ||
| @@ -6779,14 +7062,20 @@ func (i *Interactive) startAutoCompactContinuation(parent context.Context) { | |||||
| 6779 | if i.compactContinuation.reason != compactContinuationForcedLength && len(i.queued) > 0 { | 7062 | if i.compactContinuation.reason != compactContinuationForcedLength && len(i.queued) > 0 { | ||
| 6780 | next := i.queued[0] | 7063 | next := i.queued[0] | ||
| 6781 | i.queued = i.queued[1:] | 7064 | i.queued = i.queued[1:] | ||
| 6782 | - | i.resetCompactContinuationLocked() | 7065 | + | handoff, persistHandoff := i.resetCompactContinuationLocked() |
| 6783 | i.mu.Unlock() | 7066 | i.mu.Unlock() | ||
| 7067 | + | if persistHandoff { | |||
| 7068 | + | i.persistCompactHandoff(handoff) | |||
| 7069 | + | } | |||
| 6784 | i.startTurn(parent, next) | 7070 | i.startTurn(parent, next) | ||
| 6785 | return | 7071 | return | ||
| 6786 | } | 7072 | } | ||
| 6787 | if i.compactContinuation.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0 { | 7073 | if i.compactContinuation.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0 { | ||
| 6788 | - | i.resetCompactContinuationLocked() | 7074 | + | handoff, persistHandoff := i.resetCompactContinuationLocked() |
| 6789 | i.mu.Unlock() | 7075 | i.mu.Unlock() | ||
| 7076 | + | if persistHandoff { | |||
| 7077 | + | i.persistCompactHandoff(handoff) | |||
| 7078 | + | } | |||
| 6790 | i.startTurnRequest(parent, "", nil, true, false) | 7079 | i.startTurnRequest(parent, "", nil, true, false) | ||
| 6791 | return | 7080 | return | ||
| 6792 | } | 7081 | } | ||
| @@ -6827,8 +7116,10 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||||
| 6827 | i.activity.kind = activityCompactingHistory | 7116 | i.activity.kind = activityCompactingHistory | ||
| 6828 | } | 7117 | } | ||
| 6829 | i.cancelTurn = cancel | 7118 | i.cancelTurn = cancel | ||
| 7119 | + | var initialHandoff json.RawMessage | |||
| 7120 | + | persistInitialHandoff := false | |||
| 6830 | if request.origin == compactOriginManual || request.origin == compactOriginRecovery { | 7121 | if request.origin == compactOriginManual || request.origin == compactOriginRecovery { | ||
| 6831 | - | i.resetCompactContinuationLocked() | 7122 | + | initialHandoff, persistInitialHandoff = i.resetCompactContinuationLocked() |
| 6832 | } | 7123 | } | ||
| 6833 | i.statusErr = "" | 7124 | i.statusErr = "" | ||
| 6834 | i.statusOK = "" | 7125 | i.statusOK = "" | ||
| @@ -6838,6 +7129,9 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||||
| 6838 | i.scrollOffset = 0 | 7129 | i.scrollOffset = 0 | ||
| 6839 | i.helpBlock = nil | 7130 | i.helpBlock = nil | ||
| 6840 | i.mu.Unlock() | 7131 | i.mu.Unlock() | ||
| 7132 | + | if persistInitialHandoff { | |||
| 7133 | + | i.persistCompactHandoff(initialHandoff) | |||
| 7134 | + | } | |||
| 6841 | i.invalidate() | 7135 | i.invalidate() | ||
| 6842 | 7136 | ||||
| 6843 | go func() { | 7137 | go func() { | ||
| @@ -6880,6 +7174,8 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||||
| 6880 | var hasNext bool | 7174 | var hasNext bool | ||
| 6881 | var continueExisting bool | 7175 | var continueExisting bool | ||
| 6882 | var continueAutomatically bool | 7176 | var continueAutomatically bool | ||
| 7177 | + | var handoff json.RawMessage | |||
| 7178 | + | var persistHandoff bool | |||
| 6883 | 7179 | ||||
| 6884 | switch { | 7180 | switch { | ||
| 6885 | case err != nil && ctx.Err() != nil: | 7181 | case err != nil && ctx.Err() != nil: | ||
| @@ -6891,7 +7187,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||||
| 6891 | } | 7187 | } | ||
| 6892 | i.queued = nil // drop queue on cancel | 7188 | i.queued = nil // drop queue on cancel | ||
| 6893 | i.clearPendingCompactTurnLocked() | 7189 | i.clearPendingCompactTurnLocked() | ||
| 6894 | - | i.resetCompactContinuationLocked() | 7190 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() |
| 6895 | if i.agent != nil { | 7191 | if i.agent != nil { | ||
| 6896 | i.agent.DrainQueuedMessages() | 7192 | i.agent.DrainQueuedMessages() | ||
| 6897 | } | 7193 | } | ||
| @@ -6900,7 +7196,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||||
| 6900 | i.statusOK = "" | 7196 | i.statusOK = "" | ||
| 6901 | i.queued = nil // drop queue on error | 7197 | i.queued = nil // drop queue on error | ||
| 6902 | i.clearPendingCompactTurnLocked() | 7198 | i.clearPendingCompactTurnLocked() | ||
| 6903 | - | i.resetCompactContinuationLocked() | 7199 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() |
| 6904 | if i.agent != nil { | 7200 | if i.agent != nil { | ||
| 6905 | i.agent.DrainQueuedMessages() | 7201 | i.agent.DrainQueuedMessages() | ||
| 6906 | } | 7202 | } | ||
| @@ -6941,12 +7237,12 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||||
| 6941 | case continuationReason == compactContinuationForcedLength: | 7237 | case continuationReason == compactContinuationForcedLength: | ||
| 6942 | // Forced truncated-output continuation keeps its existing | 7238 | // Forced truncated-output continuation keeps its existing | ||
| 6943 | // priority over an explicitly queued prompt. | 7239 | // priority over an explicitly queued prompt. | ||
| 6944 | - | i.compactContinuation = compactContinuationState{reason: continuationReason} | 7240 | + | handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason}) |
| 6945 | continueAutomatically = true | 7241 | continueAutomatically = true | ||
| 6946 | case len(i.queued) > 0: | 7242 | case len(i.queued) > 0: | ||
| 6947 | next, i.queued = i.queued[0], i.queued[1:] | 7243 | next, i.queued = i.queued[0], i.queued[1:] | ||
| 6948 | hasNext = true | 7244 | hasNext = true | ||
| 6949 | - | i.resetCompactContinuationLocked() | 7245 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() |
| 6950 | case continuationReason == compactContinuationStructuralTail || continuationReason == compactContinuationStatusRescue: | 7246 | case continuationReason == compactContinuationStructuralTail || continuationReason == compactContinuationStatusRescue: | ||
| 6951 | if continuationReason == compactContinuationStatusRescue { | 7247 | if continuationReason == compactContinuationStatusRescue { | ||
| 6952 | attempts := 1 | 7248 | attempts := 1 | ||
| @@ -6954,19 +7250,19 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||||
| 6954 | attempts = i.compactContinuation.rescueAttempts + 1 | 7250 | attempts = i.compactContinuation.rescueAttempts + 1 | ||
| 6955 | } | 7251 | } | ||
| 6956 | if attempts > maxStatusRescueContinuations { | 7252 | if attempts > maxStatusRescueContinuations { | ||
| 6957 | - | i.resetCompactContinuationLocked() | 7253 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() |
| 6958 | break | 7254 | break | ||
| 6959 | } | 7255 | } | ||
| 6960 | - | i.compactContinuation = compactContinuationState{ | 7256 | + | handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{ |
| 6961 | reason: continuationReason, | 7257 | reason: continuationReason, | ||
| 6962 | rescueAttempts: attempts, | 7258 | rescueAttempts: attempts, | ||
| 6963 | - | } | 7259 | + | }) |
| 6964 | } else { | 7260 | } else { | ||
| 6965 | - | i.compactContinuation = compactContinuationState{reason: continuationReason} | 7261 | + | handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason}) |
| 6966 | } | 7262 | } | ||
| 6967 | continueAutomatically = true | 7263 | continueAutomatically = true | ||
| 6968 | default: | 7264 | default: | ||
| 6969 | - | i.resetCompactContinuationLocked() | 7265 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() |
| 6970 | } | 7266 | } | ||
| 6971 | } | 7267 | } | ||
| 6972 | // Keep the host busy until the hand-off decision is committed under | 7268 | // Keep the host busy until the hand-off decision is committed under | ||
| @@ -6977,6 +7273,9 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||||
| 6977 | i.compacting = false | 7273 | i.compacting = false | ||
| 6978 | i.autoCompacting = false | 7274 | i.autoCompacting = false | ||
| 6979 | i.mu.Unlock() | 7275 | i.mu.Unlock() | ||
| 7276 | + | if persistHandoff { | |||
| 7277 | + | i.persistCompactHandoff(handoff) | |||
| 7278 | + | } | |||
| 6980 | runPendingIdleWork(pendingIdleWork) | 7279 | runPendingIdleWork(pendingIdleWork) | ||
| 6981 | i.invalidate() | 7280 | i.invalidate() | ||
| 6982 | 7281 | ||||
| @@ -7156,8 +7455,10 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 7156 | // The condense flow re-fires the user's queued prompt for us, so | 7455 | // The condense flow re-fires the user's queued prompt for us, so | ||
| 7157 | // we just hand it off and exit. | 7456 | // we just hand it off and exit. | ||
| 7158 | i.mu.Lock() | 7457 | i.mu.Lock() | ||
| 7458 | + | var resetHandoff json.RawMessage | |||
| 7459 | + | var persistResetHandoff bool | |||
| 7159 | if !continueExisting { | 7460 | if !continueExisting { | ||
| 7160 | - | i.resetCompactContinuationLocked() | 7461 | + | resetHandoff, persistResetHandoff = i.resetCompactContinuationLocked() |
| 7161 | } | 7462 | } | ||
| 7162 | needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked() | 7463 | needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked() | ||
| 7163 | if needsPreCompact { | 7464 | if needsPreCompact { | ||
| @@ -7178,11 +7479,17 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 7178 | i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending...")) | 7479 | i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending...")) | ||
| 7179 | i.pendingPostCompactNote = "context auto-compacted; sending your last message" | 7480 | i.pendingPostCompactNote = "context auto-compacted; sending your last message" | ||
| 7180 | i.mu.Unlock() | 7481 | i.mu.Unlock() | ||
| 7482 | + | if persistResetHandoff { | |||
| 7483 | + | i.persistCompactHandoff(resetHandoff) | |||
| 7484 | + | } | |||
| 7181 | i.invalidate() | 7485 | i.invalidate() | ||
| 7182 | i.runCompact(parent, compactContinuationRequest{origin: compactOriginPreTurnThreshold}) | 7486 | i.runCompact(parent, compactContinuationRequest{origin: compactOriginPreTurnThreshold}) | ||
| 7183 | return | 7487 | return | ||
| 7184 | } | 7488 | } | ||
| 7185 | i.mu.Unlock() | 7489 | i.mu.Unlock() | ||
| 7490 | + | if persistResetHandoff { | |||
| 7491 | + | i.persistCompactHandoff(resetHandoff) | |||
| 7492 | + | } | |||
| 7186 | 7493 | ||||
| 7187 | ctx, cancel := context.WithCancel(parent) | 7494 | ctx, cancel := context.WithCancel(parent) | ||
| 7188 | i.mu.Lock() | 7495 | i.mu.Lock() | ||
| @@ -7349,18 +7656,23 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 7349 | continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil | 7656 | continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil | ||
| 7350 | shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked() | 7657 | shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked() | ||
| 7351 | continueStatusRescue := false | 7658 | continueStatusRescue := false | ||
| 7659 | + | var handoff json.RawMessage | |||
| 7660 | + | var persistHandoff bool | |||
| 7352 | if statusRescueActive && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil { | 7661 | if statusRescueActive && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil { | ||
| 7353 | followUpMessages := i.agent.Messages() | 7662 | followUpMessages := i.agent.Messages() | ||
| 7354 | reason := classifyCompactionContinuation(compactOriginManual, true, lastStop, lastTurnErr, followUpMessages) | 7663 | reason := classifyCompactionContinuation(compactOriginManual, true, lastStop, lastTurnErr, followUpMessages) | ||
| 7355 | if reason == compactContinuationStatusRescue && i.compactContinuation.rescueAttempts < maxStatusRescueContinuations { | 7664 | if reason == compactContinuationStatusRescue && i.compactContinuation.rescueAttempts < maxStatusRescueContinuations { | ||
| 7356 | - | i.compactContinuation.rescueAttempts++ | 7665 | + | handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{ |
| 7666 | + | reason: compactContinuationStatusRescue, | |||
| 7667 | + | rescueAttempts: i.compactContinuation.rescueAttempts + 1, | |||
| 7668 | + | }) | |||
| 7357 | continueStatusRescue = true | 7669 | continueStatusRescue = true | ||
| 7358 | } else { | 7670 | } else { | ||
| 7359 | - | i.resetCompactContinuationLocked() | 7671 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() |
| 7360 | } | 7672 | } | ||
| 7361 | } | 7673 | } | ||
| 7362 | if !continueStatusRescue && (ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) { | 7674 | if !continueStatusRescue && (ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) { | ||
| 7363 | - | i.resetCompactContinuationLocked() | 7675 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() |
| 7364 | } | 7676 | } | ||
| 7365 | // The agent run can finish before the paced final text reaches the | 7677 | // The agent run can finish before the paced final text reaches the | ||
| 7366 | // transcript. A compaction replaces that transcript, so it must never | 7678 | // transcript. A compaction replaces that transcript, so it must never | ||
| @@ -7372,6 +7684,9 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 7372 | alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact) | 7684 | alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact) | ||
| 7373 | i.busy = hasNext || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact | 7685 | i.busy = hasNext || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact | ||
| 7374 | i.mu.Unlock() | 7686 | i.mu.Unlock() | ||
| 7687 | + | if persistHandoff { | |||
| 7688 | + | i.persistCompactHandoff(handoff) | |||
| 7689 | + | } | |||
| 7375 | if alertReason != "" { | 7690 | if alertReason != "" { | ||
| 7376 | i.scheduleMainAlert(alertReason) | 7691 | i.scheduleMainAlert(alertReason) | ||
| 7377 | } | 7692 | } | ||
| @@ -7866,7 +8181,6 @@ func (i *Interactive) applyAgentPromptConfig(ag *core.Agent, system string, tool | |||||
| 7866 | // prepareReplacementAgentLocked applies session-wide capability policy before | 8181 | // prepareReplacementAgentLocked applies session-wide capability policy before | ||
| 7867 | // ag becomes visible to prompt submission. The caller holds agentMu and i.mu. | 8182 | // ag becomes visible to prompt submission. The caller holds agentMu and i.mu. | ||
| 7868 | func (i *Interactive) prepareReplacementAgentLocked(ag *core.Agent) { | 8183 | func (i *Interactive) prepareReplacementAgentLocked(ag *core.Agent) { | ||
| 7869 | - | i.resetCompactContinuationLocked() | |||
| 7870 | if ag == nil { | 8184 | if ag == nil { | ||
| 7871 | i.setWebSearchAvailable(false) | 8185 | i.setWebSearchAvailable(false) | ||
| 7872 | return | 8186 | return | ||
| @@ -8778,11 +9092,27 @@ func (i *Interactive) doSessionImport(src string) { | |||||
| 8778 | return | 9092 | return | ||
| 8779 | } | 9093 | } | ||
| 8780 | i.restoreLoadedSessionTitle() | 9094 | i.restoreLoadedSessionTitle() | ||
| 9095 | + | state := i.restoreCurrentCompactHandoff() | |||
| 8781 | i.mu.Lock() | 9096 | i.mu.Lock() | ||
| 8782 | i.statusOK = "imported and switched to session " + friendlyPath(newPath) | 9097 | i.statusOK = "imported and switched to session " + friendlyPath(newPath) | ||
| 8783 | i.statusErr = "" | 9098 | i.statusErr = "" | ||
| 9099 | + | if i.agent != nil { | |||
| 9100 | + | i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages()) | |||
| 9101 | + | i.cumUsage = i.agent.Cost() | |||
| 9102 | + | last := i.agent.LastTurnUsage() | |||
| 9103 | + | i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens | |||
| 9104 | + | if len(i.view.Messages) > initialResumeTailLimit { | |||
| 9105 | + | i.view.TailLimit = initialResumeTailLimit | |||
| 9106 | + | } else { | |||
| 9107 | + | i.view.TailLimit = 0 | |||
| 9108 | + | } | |||
| 9109 | + | i.view.InvalidateRenderCache() | |||
| 9110 | + | } | |||
| 8784 | i.mu.Unlock() | 9111 | i.mu.Unlock() | ||
| 8785 | i.invalidate() | 9112 | i.invalidate() | ||
| 9113 | + | if state.reason != compactContinuationNone { | |||
| 9114 | + | i.startRestoredCompactHandoff(i.runCtx) | |||
| 9115 | + | } | |||
| 8786 | } | 9116 | } | ||
| 8787 | 9117 | ||||
| 8788 | // defaultExportDir returns ~/Downloads when it exists, or ~ as a | 9118 | // defaultExportDir returns ~/Downloads when it exists, or ~ as a | ||
| @@ -8997,6 +9327,7 @@ func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo in | |||||
| 8997 | i.setSessionTreeError("tree: checkout failed: " + err.Error()) | 9327 | i.setSessionTreeError("tree: checkout failed: " + err.Error()) | ||
| 8998 | return | 9328 | return | ||
| 8999 | } | 9329 | } | ||
| 9330 | + | state := i.restoreCurrentCompactHandoff() | |||
| 9000 | 9331 | ||||
| 9001 | // LoadSession is intentionally the old func(string) error callback: the | 9332 | // LoadSession is intentionally the old func(string) error callback: the | ||
| 9002 | // CLI and embedders already own agent/session swapping. Refresh the view | 9333 | // CLI and embedders already own agent/session swapping. Refresh the view | ||
| @@ -9032,6 +9363,9 @@ func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo in | |||||
| 9032 | // Submit, startTurn, or queue a message here; the restored user draft is | 9363 | // Submit, startTurn, or queue a message here; the restored user draft is | ||
| 9033 | // submitted only by a later explicit Enter. | 9364 | // submitted only by a later explicit Enter. | ||
| 9034 | i.invalidate() | 9365 | i.invalidate() | ||
| 9366 | + | if state.reason != compactContinuationNone { | |||
| 9367 | + | i.startRestoredCompactHandoff(i.runCtx) | |||
| 9368 | + | } | |||
| 9035 | } | 9369 | } | ||
| 9036 | 9370 | ||||
| 9037 | // applyForkSelection branches the current session at msgIdx+1 (so | 9371 | // applyForkSelection branches the current session at msgIdx+1 (so | ||
| @@ -9079,12 +9413,16 @@ func (i *Interactive) applyForkSelection(msgIdx int) { | |||||
| 9079 | i.invalidate() | 9413 | i.invalidate() | ||
| 9080 | return | 9414 | return | ||
| 9081 | } | 9415 | } | ||
| 9416 | + | state := i.restoreCurrentCompactHandoff() | |||
| 9082 | i.resetSessionTitleForFreshBranch() | 9417 | i.resetSessionTitleForFreshBranch() | ||
| 9083 | i.mu.Lock() | 9418 | i.mu.Lock() | ||
| 9084 | i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath) | 9419 | i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath) | ||
| 9085 | i.statusErr = "" | 9420 | i.statusErr = "" | ||
| 9086 | i.mu.Unlock() | 9421 | i.mu.Unlock() | ||
| 9087 | i.invalidate() | 9422 | i.invalidate() | ||
| 9423 | + | if state.reason != compactContinuationNone { | |||
| 9424 | + | i.startRestoredCompactHandoff(i.runCtx) | |||
| 9425 | + | } | |||
| 9088 | } | 9426 | } | ||
| 9089 | 9427 | ||||
| 9090 | // formatInt is a tiny strconv.Itoa shim; keeps the handler above | 9428 | // formatInt is a tiny strconv.Itoa shim; keeps the handler above | ||
M packages/core/core_test.go +40 -0
| @@ -256,6 +256,46 @@ func TestSessionRetainsExtensionStateWithoutMessages(t *testing.T) { | |||
| 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 | 299 | func TestCostAdd(t *testing.T) { | |
| 260 | 300 | var c CostTracker | |
| 261 | 301 | 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 CostTracker | 300 | 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 { | |||
| 57 | 57 | Version string `json:"version"` | |
| 58 | 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 | 65 | // Parent is the ID of the session this one was forked from, or | |
| 61 | 66 | // empty for top-level sessions. The tree picker walks parents | |
| 62 | 67 | // upward and sibling files (same cwd dir, same parent ID) | |
| @@ -1198,6 +1203,28 @@ func (s *Session) UpdateModel(providerName, model string) error { | |||
| 1198 | 1203 | 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 | 1228 | // UpdateTitle records a session title without adding anything to the | |
| 1202 | 1229 | // conversation transcript. The title is kept in memory as well so the live | |
| 1203 | 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 | 1297 | flushErr := s.buf.Flush() | |
| 1271 | 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 | 1300 | // Best-effort cleanup. We deliberately don't propagate the | |
| 1274 | 1301 | // remove error: if it fails (file already gone, perms changed) | |
| 1275 | 1302 | // 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, or | 65 | // Parent is the ID of the session this one was forked from, or | ||
| 61 | // empty for top-level sessions. The tree picker walks parents | 66 | // 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 nil | 1203 | 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 the | 1228 | // UpdateTitle records a session title without adding anything to the | ||
| 1202 | // conversation transcript. The title is kept in memory as well so the live | 1229 | // 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 the | 1300 | // 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) { | |||
| 215 | 215 | ||
| 216 | 216 | // Write a fresh meta row claiming ownership. | |
| 217 | 217 | 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...), | |
| 225 | 226 | } | |
| 226 | 227 | metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &importMeta}) | |
| 227 | 228 | if err != nil { | |
| @@ -313,7 +314,8 @@ func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hi | |||
| 313 | 314 | if err != nil { | |
| 314 | 315 | 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 | 321 | // BranchSessionHiddenFromHistory creates a hidden tree branch from a | |
| @@ -343,7 +345,7 @@ func BranchSessionHiddenFromHistory(parentPath, root, cwd, version string, segme | |||
| 343 | 345 | if err != nil { | |
| 344 | 346 | 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 | 351 | // readExtensionStateAtFork reconstructs the latest extension snapshot that was | |
| @@ -423,7 +425,14 @@ func readExtensionStateAtFork(path string, limit int) (map[string]json.RawMessag | |||
| 423 | 425 | 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 | 436 | dir := SessionsDir(root, cwd) | |
| 428 | 437 | if err := os.MkdirAll(dir, 0o755); err != nil { | |
| 429 | 438 | return "", err | |
| @@ -464,6 +473,7 @@ func writeBranchSession(root, cwd, version string, parent SessionMeta, messages | |||
| 464 | 473 | Parent: parent.ID, | |
| 465 | 474 | ForkPoint: limit, | |
| 466 | 475 | HideFromSessions: hideFromSessions, | |
| 476 | + | CompactHandoff: append(json.RawMessage(nil), compactHandoff...), | |
| 467 | 477 | } | |
| 468 | 478 | metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta}) | |
| 469 | 479 | 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 "", err | 315 | 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 a | 321 | // 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 "", err | 346 | 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 was | 351 | // 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, nil | 425 | 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 "", err | 438 | 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) { | |||
| 29 | 29 | Role: provider.RoleAssistant, | |
| 30 | 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 | 36 | _ = sess.Close() | |
| 33 | 37 | ||
| 34 | 38 | // Export to a directory — helper should build a name inside it. | |
| @@ -70,6 +74,9 @@ func TestSessionExportImportRoundTrip(t *testing.T) { | |||
| 70 | 74 | if imported.Meta.Model != "claude-opus-4-7" { | |
| 71 | 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 | 80 | if len(msgs) != 2 { | |
| 74 | 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 | 592 | func TestBranchSessionCopiesExtensionStateAtFork(t *testing.T) { | |
| 498 | 593 | root := t.TempDir() | |
| 499 | 594 | 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" | ||