detached 63f7001

Merge pull request #78 from bnema/feat/subagent-auto-compaction

feat(subagents): compact context proactively
commit
63f7001acc36bf61bde6eda8f0e14c8ab06e6ddd
author
bnema · · 2026-08-08 09:40 UTC
committer
GitHub · · 2026-08-08 09:40 UTC
merge of
06b32e8 + 2d66a41 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
16 files · +1172 -107
M README.md +1 -1
@@ -409,7 +409,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
409409 - **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.
410410 - **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`.
411411 - **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.
412-- **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), `90%`, or `95%` 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.
412+- **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), `90%`, or `95%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and before sub-agent follow-up turns, and persists as `auto_compact_threshold`. Context accounting includes uncached input plus provider-reported cache reads and writes, so cached turns still trigger compaction. 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.
413413 - **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.
414414 - **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`.
415415 - **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`.
@@ -409,7 +409,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
409 - **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.409 - **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.
410 - **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`.410 - **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`.
411 - **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.411 - **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.
412-- **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), `90%`, or `95%` 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.412+- **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), `90%`, or `95%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and before sub-agent follow-up turns, and persists as `auto_compact_threshold`. Context accounting includes uncached input plus provider-reported cache reads and writes, so cached turns still trigger compaction. 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.
413 - **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.413 - **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.
414 - **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`.414 - **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`.
415 - **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`.415 - **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`.
M packages/agent/botcmd.go +2 -2
@@ -205,7 +205,7 @@ func botLogs(spec *botSpec, rawTail []string) error {
205205 }
206206
207207 // botRun starts the polling loop in the foreground. Ctrl+C stops it.
208-func botRun(spec *botSpec, rawTail []string, version string) error {
208+func botRun(spec *botSpec, rawTail []string, version string) (runErr error) {
209209 // Parse only a small subset of flags relevant to bot run. We reuse
210210 // the main args parser so --provider/--model/--cwd/--api-key/--reasoning
211211 // behave the same as in the tui.
@@ -252,7 +252,7 @@ func botRun(spec *botSpec, rawTail []string, version string) error {
252252 agent.OnTranscriptCompacted = func(msgs []provider.Message) {
253253 _ = sess.AppendCompaction(msgs)
254254 }
255- defer sess.Close()
255+ defer func() { runErr = joinSessionCloseError(runErr, sess) }()
256256 } else {
257257 fmt.Fprintln(os.Stderr, "session:", serr)
258258 }
@@ -205,7 +205,7 @@ func botLogs(spec *botSpec, rawTail []string) error {
205 }205 }
206 206
207 // botRun starts the polling loop in the foreground. Ctrl+C stops it.207 // botRun starts the polling loop in the foreground. Ctrl+C stops it.
208-func botRun(spec *botSpec, rawTail []string, version string) error {208+func botRun(spec *botSpec, rawTail []string, version string) (runErr error) {
209 // Parse only a small subset of flags relevant to bot run. We reuse209 // Parse only a small subset of flags relevant to bot run. We reuse
210 // the main args parser so --provider/--model/--cwd/--api-key/--reasoning210 // the main args parser so --provider/--model/--cwd/--api-key/--reasoning
211 // behave the same as in the tui.211 // behave the same as in the tui.
@@ -252,7 +252,7 @@ func botRun(spec *botSpec, rawTail []string, version string) error {
252 agent.OnTranscriptCompacted = func(msgs []provider.Message) {252 agent.OnTranscriptCompacted = func(msgs []provider.Message) {
253 _ = sess.AppendCompaction(msgs)253 _ = sess.AppendCompaction(msgs)
254 }254 }
255- defer sess.Close()255+ defer func() { runErr = joinSessionCloseError(runErr, sess) }()
256 } else {256 } else {
257 fmt.Fprintln(os.Stderr, "session:", serr)257 fmt.Fprintln(os.Stderr, "session:", serr)
258 }258 }
M packages/agent/build.go +14 -5
@@ -45,6 +45,11 @@ type Resolved struct {
4545 MaxSteps int
4646 Sandbox *tools.Sandbox
4747
48+ // ContextWindow and MaxOutput retain the effective model metadata,
49+ // including metadata synthesized for valid open-catalog local and routed
50+ // models that provider.FindModel cannot rediscover later.
51+ ContextWindow int
52+
4853 // MaxOutput is the resolved model's maximum output-token budget
4954 // (from the catalog). Passed to the agent so each turn requests
5055 // the model's full output capacity instead of the provider's
@@ -684,11 +689,13 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
684689 if err != nil {
685690 if cfg, ok := provider.CustomProviders()[provName]; ok {
686691 resolvedModel = provider.Model{
687- Provider: provName,
688- ID: model,
689- DisplayName: model,
690- BaseURL: cfg.BaseURL,
691- Source: "user",
692+ Provider: provName,
693+ ID: model,
694+ DisplayName: model,
695+ ContextWindow: 128000,
696+ MaxOutput: 16384,
697+ BaseURL: cfg.BaseURL,
698+ Source: "user",
692699 }
693700 err = nil
694701 }
@@ -924,6 +931,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
924931 ToolSummary: summaries,
925932 SystemPrompt: sys,
926933 MaxSteps: max,
934+ ContextWindow: resolvedModel.ContextWindow,
927935 MaxOutput: resolvedModel.MaxOutput,
928936 Sandbox: sandbox,
929937 SkillTool: skillTool,
@@ -1245,6 +1253,7 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) {
12451253 func (r Resolved) NewAgent() *core.Agent {
12461254 a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
12471255 a.MaxSteps = r.MaxSteps
1256+ a.ContextWindow = r.ContextWindow
12481257 a.MaxTokens = r.MaxOutput
12491258 a.Reasoning = r.Reasoning
12501259 a.Temperature = r.Temperature
@@ -45,6 +45,11 @@ type Resolved struct {
45 MaxSteps int45 MaxSteps int
46 Sandbox *tools.Sandbox46 Sandbox *tools.Sandbox
47 47
48+ // ContextWindow and MaxOutput retain the effective model metadata,
49+ // including metadata synthesized for valid open-catalog local and routed
50+ // models that provider.FindModel cannot rediscover later.
51+ ContextWindow int
52+
48 // MaxOutput is the resolved model's maximum output-token budget53 // MaxOutput is the resolved model's maximum output-token budget
49 // (from the catalog). Passed to the agent so each turn requests54 // (from the catalog). Passed to the agent so each turn requests
50 // the model's full output capacity instead of the provider's55 // the model's full output capacity instead of the provider's
@@ -684,11 +689,13 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
684 if err != nil {689 if err != nil {
685 if cfg, ok := provider.CustomProviders()[provName]; ok {690 if cfg, ok := provider.CustomProviders()[provName]; ok {
686 resolvedModel = provider.Model{691 resolvedModel = provider.Model{
687- Provider: provName,692+ Provider: provName,
688- ID: model,693+ ID: model,
689- DisplayName: model,694+ DisplayName: model,
690- BaseURL: cfg.BaseURL,695+ ContextWindow: 128000,
691- Source: "user",696+ MaxOutput: 16384,
697+ BaseURL: cfg.BaseURL,
698+ Source: "user",
692 }699 }
693 err = nil700 err = nil
694 }701 }
@@ -924,6 +931,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
924 ToolSummary: summaries,931 ToolSummary: summaries,
925 SystemPrompt: sys,932 SystemPrompt: sys,
926 MaxSteps: max,933 MaxSteps: max,
934+ ContextWindow: resolvedModel.ContextWindow,
927 MaxOutput: resolvedModel.MaxOutput,935 MaxOutput: resolvedModel.MaxOutput,
928 Sandbox: sandbox,936 Sandbox: sandbox,
929 SkillTool: skillTool,937 SkillTool: skillTool,
@@ -1245,6 +1253,7 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) {
1245 func (r Resolved) NewAgent() *core.Agent {1253 func (r Resolved) NewAgent() *core.Agent {
1246 a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)1254 a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
1247 a.MaxSteps = r.MaxSteps1255 a.MaxSteps = r.MaxSteps
1256+ a.ContextWindow = r.ContextWindow
1248 a.MaxTokens = r.MaxOutput1257 a.MaxTokens = r.MaxOutput
1249 a.Reasoning = r.Reasoning1258 a.Reasoning = r.Reasoning
1250 a.Temperature = r.Temperature1259 a.Temperature = r.Temperature
M packages/agent/build_test.go +10 -1
@@ -753,6 +753,9 @@ func TestResolveOpenRouterPreservesSavedRoutedModelID(t *testing.T) {
753753 if r.MaxOutput != 64000 {
754754 t.Fatalf("MaxOutput = %d, want synthetic gateway default 64000", r.MaxOutput)
755755 }
756+ if r.ContextWindow != 1_000_000 || r.NewAgent().ContextWindow != 1_000_000 {
757+ t.Fatalf("resolved/agent context window = %d/%d, want synthetic gateway metadata", r.ContextWindow, r.NewAgent().ContextWindow)
758+ }
756759 }
757760
758761 func TestResolveGatewayPlainUnknownModelFallsBack(t *testing.T) {
@@ -864,6 +867,9 @@ func TestResolveLlamaCPPUsesRouterInferenceURL(t *testing.T) {
864867 if r.Credential != "local" || !r.HasCredential() {
865868 t.Fatalf("credential = %q", r.Credential)
866869 }
870+ if r.ContextWindow != 128000 || r.NewAgent().ContextWindow != 128000 {
871+ t.Fatalf("resolved/agent context window = %d/%d, want synthesized local metadata", r.ContextWindow, r.NewAgent().ContextWindow)
872+ }
867873 if got := r.NewClient().Name(); got != provider.LlamaCPPProviderID {
868874 t.Fatalf("client name = %q, want %q", got, provider.LlamaCPPProviderID)
869875 }
@@ -896,7 +902,7 @@ func TestResolveCustomProviderModelBaseURLBeatsProviderBaseURL(t *testing.T) {
896902 "baseUrl": "https://provider.example.com/v1",
897903 "api": "openai",
898904 "models": [
899- {"id": "fast", "baseUrl": "https://model.example.com/v1"}
905+ {"id": "fast", "baseUrl": "https://model.example.com/v1", "contextWindow": 65536}
900906 ]
901907 }
902908 }
@@ -918,6 +924,9 @@ func TestResolveCustomProviderModelBaseURLBeatsProviderBaseURL(t *testing.T) {
918924 if r.BaseURL != "https://model.example.com/v1" {
919925 t.Fatalf("BaseURL = %q, want model-level baseUrl", r.BaseURL)
920926 }
927+ if r.ContextWindow != 65536 || r.NewAgent().ContextWindow != 65536 {
928+ t.Fatalf("resolved/agent context window = %d/%d, want retained user-model metadata", r.ContextWindow, r.NewAgent().ContextWindow)
929+ }
921930 }
922931
923932 func TestCustomProviderUsesOpenAIResponsesAPI(t *testing.T) {
@@ -753,6 +753,9 @@ func TestResolveOpenRouterPreservesSavedRoutedModelID(t *testing.T) {
753 if r.MaxOutput != 64000 {753 if r.MaxOutput != 64000 {
754 t.Fatalf("MaxOutput = %d, want synthetic gateway default 64000", r.MaxOutput)754 t.Fatalf("MaxOutput = %d, want synthetic gateway default 64000", r.MaxOutput)
755 }755 }
756+ if r.ContextWindow != 1_000_000 || r.NewAgent().ContextWindow != 1_000_000 {
757+ t.Fatalf("resolved/agent context window = %d/%d, want synthetic gateway metadata", r.ContextWindow, r.NewAgent().ContextWindow)
758+ }
756 }759 }
757 760
758 func TestResolveGatewayPlainUnknownModelFallsBack(t *testing.T) {761 func TestResolveGatewayPlainUnknownModelFallsBack(t *testing.T) {
@@ -864,6 +867,9 @@ func TestResolveLlamaCPPUsesRouterInferenceURL(t *testing.T) {
864 if r.Credential != "local" || !r.HasCredential() {867 if r.Credential != "local" || !r.HasCredential() {
865 t.Fatalf("credential = %q", r.Credential)868 t.Fatalf("credential = %q", r.Credential)
866 }869 }
870+ if r.ContextWindow != 128000 || r.NewAgent().ContextWindow != 128000 {
871+ t.Fatalf("resolved/agent context window = %d/%d, want synthesized local metadata", r.ContextWindow, r.NewAgent().ContextWindow)
872+ }
867 if got := r.NewClient().Name(); got != provider.LlamaCPPProviderID {873 if got := r.NewClient().Name(); got != provider.LlamaCPPProviderID {
868 t.Fatalf("client name = %q, want %q", got, provider.LlamaCPPProviderID)874 t.Fatalf("client name = %q, want %q", got, provider.LlamaCPPProviderID)
869 }875 }
@@ -896,7 +902,7 @@ func TestResolveCustomProviderModelBaseURLBeatsProviderBaseURL(t *testing.T) {
896 "baseUrl": "https://provider.example.com/v1",902 "baseUrl": "https://provider.example.com/v1",
897 "api": "openai",903 "api": "openai",
898 "models": [904 "models": [
899- {"id": "fast", "baseUrl": "https://model.example.com/v1"}905+ {"id": "fast", "baseUrl": "https://model.example.com/v1", "contextWindow": 65536}
900 ]906 ]
901 }907 }
902 }908 }
@@ -918,6 +924,9 @@ func TestResolveCustomProviderModelBaseURLBeatsProviderBaseURL(t *testing.T) {
918 if r.BaseURL != "https://model.example.com/v1" {924 if r.BaseURL != "https://model.example.com/v1" {
919 t.Fatalf("BaseURL = %q, want model-level baseUrl", r.BaseURL)925 t.Fatalf("BaseURL = %q, want model-level baseUrl", r.BaseURL)
920 }926 }
927+ if r.ContextWindow != 65536 || r.NewAgent().ContextWindow != 65536 {
928+ t.Fatalf("resolved/agent context window = %d/%d, want retained user-model metadata", r.ContextWindow, r.NewAgent().ContextWindow)
929+ }
921 }930 }
922 931
923 func TestCustomProviderUsesOpenAIResponsesAPI(t *testing.T) {932 func TestCustomProviderUsesOpenAIResponsesAPI(t *testing.T) {
M packages/agent/cli.go +106 -37
@@ -351,6 +351,16 @@ func closeAgentLSP(ag *core.Agent) {
351351 }
352352 }
353353
354+func joinSessionCloseError(err error, sess *core.Session) error {
355+ if sess == nil {
356+ return err
357+ }
358+ if closeErr := sess.Close(); closeErr != nil {
359+ return errors.Join(err, fmt.Errorf("close session: %w", closeErr))
360+ }
361+ return err
362+}
363+
354364 func closeResolvedLSP(r Resolved) {
355365 _ = tools.CloseLSPManagers(r.ToolRegistry)
356366 }
@@ -443,7 +453,7 @@ func prepareSessionResumeWithOptions(path string, current *core.Agent, currentPr
443453 keepSession := false
444454 defer func() {
445455 if !keepSession {
446- _ = sess.Close()
456+ err = joinSessionCloseError(err, sess)
447457 }
448458 }()
449459
@@ -571,15 +581,16 @@ func applySessionResume(sess *core.Session, ag *core.Agent, currentProvider, cur
571581 }
572582 candidate, err := prepareSessionResumeWithOptions(sess.Path, ag, currentProvider, currentModel, explicitProvider, explicitModel, buildAgentFor)
573583 if err != nil {
574- _ = sess.Close()
575- return sessionResumeCandidate{}, err
584+ return sessionResumeCandidate{}, joinSessionCloseError(err, sess)
576585 }
577586 if !candidate.rebuilt {
578587 candidate.agent.SetMessages(candidate.messages)
579588 candidate.agent.SeedCost(candidate.cumulative)
580589 candidate.agent.SeedLastTurnUsage(candidate.lastTurn)
581590 }
582- _ = sess.Close()
591+ if closeErr := sess.Close(); closeErr != nil {
592+ return sessionResumeCandidate{}, errors.Join(fmt.Errorf("close session: %w", closeErr), joinSessionCloseError(nil, candidate.session))
593+ }
583594 return candidate, nil
584595 }
585596
@@ -879,7 +890,7 @@ func writePrintStats(path, providerID, model string, usage provider.Usage, elaps
879890 return nil
880891 }
881892
882-func runPrintMode(ctx context.Context, args Args, version string) error {
893+func runPrintMode(ctx context.Context, args Args, version string) (runErr error) {
883894 if args.NoYolo {
884895 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in print mode (no interactive prompt available); tools will run without confirmation")
885896 }
@@ -911,7 +922,7 @@ func runPrintMode(ctx context.Context, args Args, version string) error {
911922 }
912923 r.Provider, r.Model = providerName, model
913924 ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
914- defer sess.Close()
925+ defer func() { runErr = joinSessionCloseError(runErr, sess) }()
915926 }
916927 announceSession(extMgr, sess)
917928
@@ -942,7 +953,12 @@ func runPrintMode(ctx context.Context, args Args, version string) error {
942953 if recovery.Compacted {
943954 transcriptStart = recovery.OutputStart
944955 }
945- WriteNewTranscript(ag, sess, transcriptStart)
956+ if persistErr := WriteNewTranscript(ag, sess, transcriptStart); persistErr != nil {
957+ if err != nil {
958+ return errors.Join(err, persistErr)
959+ }
960+ return persistErr
961+ }
946962 if err != nil {
947963 return err
948964 }
@@ -952,7 +968,7 @@ func runPrintMode(ctx context.Context, args Args, version string) error {
952968 return nil
953969 }
954970
955-func runStreamMode(ctx context.Context, args Args, version string) error {
971+func runStreamMode(ctx context.Context, args Args, version string) (runErr error) {
956972 if args.NoYolo {
957973 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in stream mode (no interactive prompt available); tools will run without confirmation")
958974 }
@@ -984,7 +1000,7 @@ func runStreamMode(ctx context.Context, args Args, version string) error {
9841000 }
9851001 r.Provider, r.Model = providerName, model
9861002 ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
987- defer sess.Close()
1003+ defer func() { runErr = joinSessionCloseError(runErr, sess) }()
9881004 }
9891005 announceSession(extMgr, sess)
9901006
@@ -1016,7 +1032,12 @@ func runStreamMode(ctx context.Context, args Args, version string) error {
10161032 if recovery.Compacted {
10171033 transcriptStart = recovery.OutputStart
10181034 }
1019- WriteNewTranscript(ag, sess, transcriptStart)
1035+ if persistErr := WriteNewTranscript(ag, sess, transcriptStart); persistErr != nil {
1036+ if err != nil {
1037+ return errors.Join(err, persistErr)
1038+ }
1039+ return persistErr
1040+ }
10201041 return err
10211042 }
10221043
@@ -1064,7 +1085,7 @@ func newStreamTextSink(out io.Writer) (func(core.AgentEvent), func()) {
10641085 return sink, finish
10651086 }
10661087
1067-func runJSONMode(ctx context.Context, args Args, version string) error {
1088+func runJSONMode(ctx context.Context, args Args, version string) (runErr error) {
10681089 if args.NoYolo {
10691090 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in json mode (no interactive prompt available); tools will run without confirmation")
10701091 }
@@ -1096,7 +1117,7 @@ func runJSONMode(ctx context.Context, args Args, version string) error {
10961117 }
10971118 r.Provider, r.Model = providerName, model
10981119 ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
1099- defer sess.Close()
1120+ defer func() { runErr = joinSessionCloseError(runErr, sess) }()
11001121 }
11011122 announceSession(extMgr, sess)
11021123
@@ -1131,7 +1152,14 @@ func runJSONMode(ctx context.Context, args Args, version string) error {
11311152 if recovery.Compacted {
11321153 transcriptStart = recovery.OutputStart
11331154 }
1134- WriteNewTranscript(ag, sess, transcriptStart)
1155+ if persistErr := WriteNewTranscript(ag, sess, transcriptStart); persistErr != nil {
1156+ finalErr := persistErr
1157+ if err != nil {
1158+ finalErr = errors.Join(err, persistErr)
1159+ }
1160+ _ = enc.Encode(map[string]any{"type": "error", "message": finalErr.Error()})
1161+ return finalErr
1162+ }
11351163 return err
11361164 }
11371165
@@ -1313,7 +1341,7 @@ func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy {
13131341 }
13141342 }
13151343
1316-func runInteractive(ctx context.Context, args Args, version string) error {
1344+func runInteractive(ctx context.Context, args Args, version string) (runErr error) {
13171345 initialCfg, _ := LoadConfig()
13181346 // Resolve WITHOUT requiring credentials.
13191347 r, err := Resolve(args, false)
@@ -1741,9 +1769,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
17411769 defer func() {
17421770 persistMu.Lock()
17431771 defer persistMu.Unlock()
1744- if sess != nil {
1745- sess.Close()
1746- }
1772+ runErr = joinSessionCloseError(runErr, sess)
17471773 }()
17481774
17491775 // persistMessage is the per-message hook bound to the agent. It
@@ -1854,7 +1880,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
18541880
18551881 // loadSession replaces the current session with the one at path and
18561882 // hands its messages to the agent. Used by the /sessions picker.
1857- loadSession := func(path string) error {
1883+ loadSession := func(path string) (loadErr error) {
18581884 // Hold the transition lock from the pre-flush through the commit.
18591885 // Persistence callbacks take the read side, so an active session
18601886 // cannot be snapshotted before its lazy writes land or overwritten by
@@ -1875,7 +1901,12 @@ func runInteractive(ctx context.Context, args Args, version string) error {
18751901 // than the public callback, which would acquire the read lock held
18761902 // by this transition.
18771903 if oldSess != nil {
1878- writeNewTranscriptLocked(currentAg, oldSess, sessBaselineMsgs)
1904+ next, flushErr := writeNewTranscriptLocked(currentAg, oldSess, sessBaselineMsgs)
1905+ sessBaselineMsgs = next
1906+ if flushErr != nil {
1907+ persistMu.Unlock()
1908+ return fmt.Errorf("flush current session: %w", flushErr)
1909+ }
18791910 }
18801911 currentMessageCount := len(currentAg.Messages())
18811912 currentCost := currentAg.Cost()
@@ -1892,7 +1923,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
18921923 committed := false
18931924 defer func() {
18941925 if !committed {
1895- _ = candidate.session.Close()
1926+ loadErr = joinSessionCloseError(loadErr, candidate.session)
18961927 }
18971928 }()
18981929
@@ -1916,8 +1947,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
19161947 return fmt.Errorf("session changed while loading; please try again")
19171948 }
19181949 newStates := copyExtensionStates(candidate.session.ExtensionState)
1950+ var oldCloseErr error
19191951 if oldSess != nil {
1920- _ = oldSess.Close()
1952+ oldCloseErr = oldSess.Close()
19211953 }
19221954 sess = candidate.session
19231955 sessionTitlePending = candidate.session != nil && candidate.session.Meta.Parent != "" && candidate.session.Title == "" && candidate.fullMessageCount <= candidate.session.Meta.ForkPoint
@@ -1958,6 +1990,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
19581990 extMgr.EmitSessionEvent("session_forked", sessionContext(candidate.session), newStates)
19591991 }
19601992 extMgr.EmitSessionEvent("session_switched", sessionContext(candidate.session), newStates)
1993+ if oldCloseErr != nil {
1994+ return fmt.Errorf("close current session: %w", oldCloseErr)
1995+ }
19611996 return nil
19621997 }
19631998
@@ -2108,10 +2143,18 @@ func runInteractive(ctx context.Context, args Args, version string) error {
21082143 // Commit only after every fallible operation has succeeded. Close
21092144 // the old session last so rollback never has to resurrect it.
21102145 var newStates map[string]json.RawMessage
2146+ var oldCloseErr error
21112147 persistMu.Lock()
21122148 if sess != nil {
2113- writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
2114- _ = sess.Close()
2149+ next, flushErr := writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
2150+ sessBaselineMsgs = next
2151+ if flushErr != nil {
2152+ persistMu.Unlock()
2153+ rollbackErr := joinSessionCloseError(fmt.Errorf("flush current session: %w", flushErr), newSess)
2154+ rollback()
2155+ return rollbackErr
2156+ }
2157+ oldCloseErr = sess.Close()
21152158 }
21162159 sess = newSess
21172160 if newSess != nil {
@@ -2142,6 +2185,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
21422185 if newSess != nil {
21432186 extMgr.EmitSessionEvent("session_opened", sessionContext(newSess), newStates)
21442187 }
2188+ if oldCloseErr != nil {
2189+ return fmt.Errorf("close current session: %w", oldCloseErr)
2190+ }
21452191 return nil
21462192 }
21472193
@@ -2419,8 +2465,12 @@ func runInteractive(ctx context.Context, args Args, version string) error {
24192465 if sess == nil {
24202466 return
24212467 }
2422- writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
2423- sessBaselineMsgs = len(currentAg.Messages())
2468+ next, flushErr := writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
2469+ sessBaselineMsgs = next
2470+ if flushErr != nil {
2471+ iv.ReportError(fmt.Errorf("flush session: %w", flushErr))
2472+ return
2473+ }
24242474 },
24252475 SessionTransition: newSessionTransition(&sessionTransitionMu),
24262476 Extensions: extMgr,
@@ -2539,9 +2589,14 @@ func runInteractive(ctx context.Context, args Args, version string) error {
25392589 if finalAg != nil {
25402590 persistMu.Lock()
25412591 if sess != nil {
2542- writeNewTranscriptLocked(finalAg, sess, sessBaselineMsgs)
2543- sessBaselineMsgs = len(finalAg.Messages())
2544- _ = sess.Close()
2592+ next, flushErr := writeNewTranscriptLocked(finalAg, sess, sessBaselineMsgs)
2593+ sessBaselineMsgs = next
2594+ if flushErr != nil {
2595+ fmt.Fprintf(os.Stderr, "flush session: %v\n", flushErr)
2596+ }
2597+ if closeErr := sess.Close(); closeErr != nil {
2598+ fmt.Fprintf(os.Stderr, "close session: %v\n", closeErr)
2599+ }
25452600 sess = nil
25462601 }
25472602 persistMu.Unlock()
@@ -2562,14 +2617,17 @@ func runInteractive(ctx context.Context, args Args, version string) error {
25622617 os.Exit(0)
25632618 }()
25642619
2565- runErr := iv.Run(ctx)
2620+ runErr = iv.Run(ctx)
25662621
25672622 // Flush final transcript to session (only if we had / ended up with an agent).
25682623 if finalAg := iv.Agent(); finalAg != nil {
25692624 persistMu.Lock()
25702625 if sess != nil {
2571- writeNewTranscriptLocked(finalAg, sess, sessBaselineMsgs)
2572- sessBaselineMsgs = len(finalAg.Messages())
2626+ next, flushErr := writeNewTranscriptLocked(finalAg, sess, sessBaselineMsgs)
2627+ sessBaselineMsgs = next
2628+ if flushErr != nil {
2629+ runErr = errors.Join(runErr, fmt.Errorf("flush session: %w", flushErr))
2630+ }
25732631 }
25742632 persistMu.Unlock()
25752633 }
@@ -2666,24 +2724,35 @@ func pickSession(root, cwd string) (string, error) {
26662724 // agent's transcript to the session. Used by callers that don't hold
26672725 // the persistMu (non-interactive print/json modes which run a single
26682726 // turn under their own goroutine).
2669-func WriteNewTranscript(ag *core.Agent, sess *core.Session, from int) {
2670- writeNewTranscriptLocked(ag, sess, from)
2727+func WriteNewTranscript(ag *core.Agent, sess *core.Session, from int) error {
2728+ _, err := writeNewTranscriptLocked(ag, sess, from)
2729+ return err
26712730 }
26722731
26732732 // writeNewTranscriptLocked is the same as WriteNewTranscript. The
26742733 // suffix marks that interactive callers must hold persistMu when
26752734 // invoking it so concurrent appends from the agent loop don't race
26762735 // with this catch-up flush.
2677-func writeNewTranscriptLocked(ag *core.Agent, sess *core.Session, from int) {
2736+func writeNewTranscriptLocked(ag *core.Agent, sess *core.Session, from int) (next int, err error) {
2737+ next = from
26782738 if sess == nil || ag == nil {
2679- return
2739+ return next, nil
26802740 }
26812741 msgs := ag.Messages()
26822742 for i := from; i < len(msgs); i++ {
2683- _ = sess.AppendMessage(msgs[i])
2743+ if err := sess.AppendMessage(msgs[i]); err != nil {
2744+ return next, fmt.Errorf("append transcript message %d: %w", i, err)
2745+ }
2746+ next = i + 1
26842747 }
26852748 cum := ag.Cost()
2686- _ = sess.AppendUsage(cum, cum)
2749+ if err := sess.AppendUsage(cum, cum); err != nil {
2750+ return next, fmt.Errorf("append transcript usage: %w", err)
2751+ }
2752+ if err := sess.Flush(); err != nil {
2753+ return next, fmt.Errorf("flush transcript: %w", err)
2754+ }
2755+ return next, nil
26872756 }
26882757
26892758 func readAllStdin() (string, error) {
@@ -351,6 +351,16 @@ func closeAgentLSP(ag *core.Agent) {
351 }351 }
352 }352 }
353 353
354+func joinSessionCloseError(err error, sess *core.Session) error {
355+ if sess == nil {
356+ return err
357+ }
358+ if closeErr := sess.Close(); closeErr != nil {
359+ return errors.Join(err, fmt.Errorf("close session: %w", closeErr))
360+ }
361+ return err
362+}
363+
354 func closeResolvedLSP(r Resolved) {364 func closeResolvedLSP(r Resolved) {
355 _ = tools.CloseLSPManagers(r.ToolRegistry)365 _ = tools.CloseLSPManagers(r.ToolRegistry)
356 }366 }
@@ -443,7 +453,7 @@ func prepareSessionResumeWithOptions(path string, current *core.Agent, currentPr
443 keepSession := false453 keepSession := false
444 defer func() {454 defer func() {
445 if !keepSession {455 if !keepSession {
446- _ = sess.Close()456+ err = joinSessionCloseError(err, sess)
447 }457 }
448 }()458 }()
449 459
@@ -571,15 +581,16 @@ func applySessionResume(sess *core.Session, ag *core.Agent, currentProvider, cur
571 }581 }
572 candidate, err := prepareSessionResumeWithOptions(sess.Path, ag, currentProvider, currentModel, explicitProvider, explicitModel, buildAgentFor)582 candidate, err := prepareSessionResumeWithOptions(sess.Path, ag, currentProvider, currentModel, explicitProvider, explicitModel, buildAgentFor)
573 if err != nil {583 if err != nil {
574- _ = sess.Close()584+ return sessionResumeCandidate{}, joinSessionCloseError(err, sess)
575- return sessionResumeCandidate{}, err
576 }585 }
577 if !candidate.rebuilt {586 if !candidate.rebuilt {
578 candidate.agent.SetMessages(candidate.messages)587 candidate.agent.SetMessages(candidate.messages)
579 candidate.agent.SeedCost(candidate.cumulative)588 candidate.agent.SeedCost(candidate.cumulative)
580 candidate.agent.SeedLastTurnUsage(candidate.lastTurn)589 candidate.agent.SeedLastTurnUsage(candidate.lastTurn)
581 }590 }
582- _ = sess.Close()591+ if closeErr := sess.Close(); closeErr != nil {
592+ return sessionResumeCandidate{}, errors.Join(fmt.Errorf("close session: %w", closeErr), joinSessionCloseError(nil, candidate.session))
593+ }
583 return candidate, nil594 return candidate, nil
584 }595 }
585 596
@@ -879,7 +890,7 @@ func writePrintStats(path, providerID, model string, usage provider.Usage, elaps
879 return nil890 return nil
880 }891 }
881 892
882-func runPrintMode(ctx context.Context, args Args, version string) error {893+func runPrintMode(ctx context.Context, args Args, version string) (runErr error) {
883 if args.NoYolo {894 if args.NoYolo {
884 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in print mode (no interactive prompt available); tools will run without confirmation")895 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in print mode (no interactive prompt available); tools will run without confirmation")
885 }896 }
@@ -911,7 +922,7 @@ func runPrintMode(ctx context.Context, args Args, version string) error {
911 }922 }
912 r.Provider, r.Model = providerName, model923 r.Provider, r.Model = providerName, model
913 ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }924 ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
914- defer sess.Close()925+ defer func() { runErr = joinSessionCloseError(runErr, sess) }()
915 }926 }
916 announceSession(extMgr, sess)927 announceSession(extMgr, sess)
917 928
@@ -942,7 +953,12 @@ func runPrintMode(ctx context.Context, args Args, version string) error {
942 if recovery.Compacted {953 if recovery.Compacted {
943 transcriptStart = recovery.OutputStart954 transcriptStart = recovery.OutputStart
944 }955 }
945- WriteNewTranscript(ag, sess, transcriptStart)956+ if persistErr := WriteNewTranscript(ag, sess, transcriptStart); persistErr != nil {
957+ if err != nil {
958+ return errors.Join(err, persistErr)
959+ }
960+ return persistErr
961+ }
946 if err != nil {962 if err != nil {
947 return err963 return err
948 }964 }
@@ -952,7 +968,7 @@ func runPrintMode(ctx context.Context, args Args, version string) error {
952 return nil968 return nil
953 }969 }
954 970
955-func runStreamMode(ctx context.Context, args Args, version string) error {971+func runStreamMode(ctx context.Context, args Args, version string) (runErr error) {
956 if args.NoYolo {972 if args.NoYolo {
957 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in stream mode (no interactive prompt available); tools will run without confirmation")973 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in stream mode (no interactive prompt available); tools will run without confirmation")
958 }974 }
@@ -984,7 +1000,7 @@ func runStreamMode(ctx context.Context, args Args, version string) error {
984 }1000 }
985 r.Provider, r.Model = providerName, model1001 r.Provider, r.Model = providerName, model
986 ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }1002 ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
987- defer sess.Close()1003+ defer func() { runErr = joinSessionCloseError(runErr, sess) }()
988 }1004 }
989 announceSession(extMgr, sess)1005 announceSession(extMgr, sess)
990 1006
@@ -1016,7 +1032,12 @@ func runStreamMode(ctx context.Context, args Args, version string) error {
1016 if recovery.Compacted {1032 if recovery.Compacted {
1017 transcriptStart = recovery.OutputStart1033 transcriptStart = recovery.OutputStart
1018 }1034 }
1019- WriteNewTranscript(ag, sess, transcriptStart)1035+ if persistErr := WriteNewTranscript(ag, sess, transcriptStart); persistErr != nil {
1036+ if err != nil {
1037+ return errors.Join(err, persistErr)
1038+ }
1039+ return persistErr
1040+ }
1020 return err1041 return err
1021 }1042 }
1022 1043
@@ -1064,7 +1085,7 @@ func newStreamTextSink(out io.Writer) (func(core.AgentEvent), func()) {
1064 return sink, finish1085 return sink, finish
1065 }1086 }
1066 1087
1067-func runJSONMode(ctx context.Context, args Args, version string) error {1088+func runJSONMode(ctx context.Context, args Args, version string) (runErr error) {
1068 if args.NoYolo {1089 if args.NoYolo {
1069 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in json mode (no interactive prompt available); tools will run without confirmation")1090 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in json mode (no interactive prompt available); tools will run without confirmation")
1070 }1091 }
@@ -1096,7 +1117,7 @@ func runJSONMode(ctx context.Context, args Args, version string) error {
1096 }1117 }
1097 r.Provider, r.Model = providerName, model1118 r.Provider, r.Model = providerName, model
1098 ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }1119 ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
1099- defer sess.Close()1120+ defer func() { runErr = joinSessionCloseError(runErr, sess) }()
1100 }1121 }
1101 announceSession(extMgr, sess)1122 announceSession(extMgr, sess)
1102 1123
@@ -1131,7 +1152,14 @@ func runJSONMode(ctx context.Context, args Args, version string) error {
1131 if recovery.Compacted {1152 if recovery.Compacted {
1132 transcriptStart = recovery.OutputStart1153 transcriptStart = recovery.OutputStart
1133 }1154 }
1134- WriteNewTranscript(ag, sess, transcriptStart)1155+ if persistErr := WriteNewTranscript(ag, sess, transcriptStart); persistErr != nil {
1156+ finalErr := persistErr
1157+ if err != nil {
1158+ finalErr = errors.Join(err, persistErr)
1159+ }
1160+ _ = enc.Encode(map[string]any{"type": "error", "message": finalErr.Error()})
1161+ return finalErr
1162+ }
1135 return err1163 return err
1136 }1164 }
1137 1165
@@ -1313,7 +1341,7 @@ func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy {
1313 }1341 }
1314 }1342 }
1315 1343
1316-func runInteractive(ctx context.Context, args Args, version string) error {1344+func runInteractive(ctx context.Context, args Args, version string) (runErr error) {
1317 initialCfg, _ := LoadConfig()1345 initialCfg, _ := LoadConfig()
1318 // Resolve WITHOUT requiring credentials.1346 // Resolve WITHOUT requiring credentials.
1319 r, err := Resolve(args, false)1347 r, err := Resolve(args, false)
@@ -1741,9 +1769,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1741 defer func() {1769 defer func() {
1742 persistMu.Lock()1770 persistMu.Lock()
1743 defer persistMu.Unlock()1771 defer persistMu.Unlock()
1744- if sess != nil {1772+ runErr = joinSessionCloseError(runErr, sess)
1745- sess.Close()
1746- }
1747 }()1773 }()
1748 1774
1749 // persistMessage is the per-message hook bound to the agent. It1775 // persistMessage is the per-message hook bound to the agent. It
@@ -1854,7 +1880,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1854 1880
1855 // loadSession replaces the current session with the one at path and1881 // loadSession replaces the current session with the one at path and
1856 // hands its messages to the agent. Used by the /sessions picker.1882 // hands its messages to the agent. Used by the /sessions picker.
1857- loadSession := func(path string) error {1883+ loadSession := func(path string) (loadErr error) {
1858 // Hold the transition lock from the pre-flush through the commit.1884 // Hold the transition lock from the pre-flush through the commit.
1859 // Persistence callbacks take the read side, so an active session1885 // Persistence callbacks take the read side, so an active session
1860 // cannot be snapshotted before its lazy writes land or overwritten by1886 // cannot be snapshotted before its lazy writes land or overwritten by
@@ -1875,7 +1901,12 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1875 // than the public callback, which would acquire the read lock held1901 // than the public callback, which would acquire the read lock held
1876 // by this transition.1902 // by this transition.
1877 if oldSess != nil {1903 if oldSess != nil {
1878- writeNewTranscriptLocked(currentAg, oldSess, sessBaselineMsgs)1904+ next, flushErr := writeNewTranscriptLocked(currentAg, oldSess, sessBaselineMsgs)
1905+ sessBaselineMsgs = next
1906+ if flushErr != nil {
1907+ persistMu.Unlock()
1908+ return fmt.Errorf("flush current session: %w", flushErr)
1909+ }
1879 }1910 }
1880 currentMessageCount := len(currentAg.Messages())1911 currentMessageCount := len(currentAg.Messages())
1881 currentCost := currentAg.Cost()1912 currentCost := currentAg.Cost()
@@ -1892,7 +1923,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1892 committed := false1923 committed := false
1893 defer func() {1924 defer func() {
1894 if !committed {1925 if !committed {
1895- _ = candidate.session.Close()1926+ loadErr = joinSessionCloseError(loadErr, candidate.session)
1896 }1927 }
1897 }()1928 }()
1898 1929
@@ -1916,8 +1947,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1916 return fmt.Errorf("session changed while loading; please try again")1947 return fmt.Errorf("session changed while loading; please try again")
1917 }1948 }
1918 newStates := copyExtensionStates(candidate.session.ExtensionState)1949 newStates := copyExtensionStates(candidate.session.ExtensionState)
1950+ var oldCloseErr error
1919 if oldSess != nil {1951 if oldSess != nil {
1920- _ = oldSess.Close()1952+ oldCloseErr = oldSess.Close()
1921 }1953 }
1922 sess = candidate.session1954 sess = candidate.session
1923 sessionTitlePending = candidate.session != nil && candidate.session.Meta.Parent != "" && candidate.session.Title == "" && candidate.fullMessageCount <= candidate.session.Meta.ForkPoint1955 sessionTitlePending = candidate.session != nil && candidate.session.Meta.Parent != "" && candidate.session.Title == "" && candidate.fullMessageCount <= candidate.session.Meta.ForkPoint
@@ -1958,6 +1990,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1958 extMgr.EmitSessionEvent("session_forked", sessionContext(candidate.session), newStates)1990 extMgr.EmitSessionEvent("session_forked", sessionContext(candidate.session), newStates)
1959 }1991 }
1960 extMgr.EmitSessionEvent("session_switched", sessionContext(candidate.session), newStates)1992 extMgr.EmitSessionEvent("session_switched", sessionContext(candidate.session), newStates)
1993+ if oldCloseErr != nil {
1994+ return fmt.Errorf("close current session: %w", oldCloseErr)
1995+ }
1961 return nil1996 return nil
1962 }1997 }
1963 1998
@@ -2108,10 +2143,18 @@ func runInteractive(ctx context.Context, args Args, version string) error {
2108 // Commit only after every fallible operation has succeeded. Close2143 // Commit only after every fallible operation has succeeded. Close
2109 // the old session last so rollback never has to resurrect it.2144 // the old session last so rollback never has to resurrect it.
2110 var newStates map[string]json.RawMessage2145 var newStates map[string]json.RawMessage
2146+ var oldCloseErr error
2111 persistMu.Lock()2147 persistMu.Lock()
2112 if sess != nil {2148 if sess != nil {
2113- writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)2149+ next, flushErr := writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
2114- _ = sess.Close()2150+ sessBaselineMsgs = next
2151+ if flushErr != nil {
2152+ persistMu.Unlock()
2153+ rollbackErr := joinSessionCloseError(fmt.Errorf("flush current session: %w", flushErr), newSess)
2154+ rollback()
2155+ return rollbackErr
2156+ }
2157+ oldCloseErr = sess.Close()
2115 }2158 }
2116 sess = newSess2159 sess = newSess
2117 if newSess != nil {2160 if newSess != nil {
@@ -2142,6 +2185,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
2142 if newSess != nil {2185 if newSess != nil {
2143 extMgr.EmitSessionEvent("session_opened", sessionContext(newSess), newStates)2186 extMgr.EmitSessionEvent("session_opened", sessionContext(newSess), newStates)
2144 }2187 }
2188+ if oldCloseErr != nil {
2189+ return fmt.Errorf("close current session: %w", oldCloseErr)
2190+ }
2145 return nil2191 return nil
2146 }2192 }
2147 2193
@@ -2419,8 +2465,12 @@ func runInteractive(ctx context.Context, args Args, version string) error {
2419 if sess == nil {2465 if sess == nil {
2420 return2466 return
2421 }2467 }
2422- writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)2468+ next, flushErr := writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
2423- sessBaselineMsgs = len(currentAg.Messages())2469+ sessBaselineMsgs = next
2470+ if flushErr != nil {
2471+ iv.ReportError(fmt.Errorf("flush session: %w", flushErr))
2472+ return
2473+ }
2424 },2474 },
2425 SessionTransition: newSessionTransition(&sessionTransitionMu),2475 SessionTransition: newSessionTransition(&sessionTransitionMu),
2426 Extensions: extMgr,2476 Extensions: extMgr,
@@ -2539,9 +2589,14 @@ func runInteractive(ctx context.Context, args Args, version string) error {
2539 if finalAg != nil {2589 if finalAg != nil {
2540 persistMu.Lock()2590 persistMu.Lock()
2541 if sess != nil {2591 if sess != nil {
2542- writeNewTranscriptLocked(finalAg, sess, sessBaselineMsgs)2592+ next, flushErr := writeNewTranscriptLocked(finalAg, sess, sessBaselineMsgs)
2543- sessBaselineMsgs = len(finalAg.Messages())2593+ sessBaselineMsgs = next
2544- _ = sess.Close()2594+ if flushErr != nil {
2595+ fmt.Fprintf(os.Stderr, "flush session: %v\n", flushErr)
2596+ }
2597+ if closeErr := sess.Close(); closeErr != nil {
2598+ fmt.Fprintf(os.Stderr, "close session: %v\n", closeErr)
2599+ }
2545 sess = nil2600 sess = nil
2546 }2601 }
2547 persistMu.Unlock()2602 persistMu.Unlock()
@@ -2562,14 +2617,17 @@ func runInteractive(ctx context.Context, args Args, version string) error {
2562 os.Exit(0)2617 os.Exit(0)
2563 }()2618 }()
2564 2619
2565- runErr := iv.Run(ctx)2620+ runErr = iv.Run(ctx)
2566 2621
2567 // Flush final transcript to session (only if we had / ended up with an agent).2622 // Flush final transcript to session (only if we had / ended up with an agent).
2568 if finalAg := iv.Agent(); finalAg != nil {2623 if finalAg := iv.Agent(); finalAg != nil {
2569 persistMu.Lock()2624 persistMu.Lock()
2570 if sess != nil {2625 if sess != nil {
2571- writeNewTranscriptLocked(finalAg, sess, sessBaselineMsgs)2626+ next, flushErr := writeNewTranscriptLocked(finalAg, sess, sessBaselineMsgs)
2572- sessBaselineMsgs = len(finalAg.Messages())2627+ sessBaselineMsgs = next
2628+ if flushErr != nil {
2629+ runErr = errors.Join(runErr, fmt.Errorf("flush session: %w", flushErr))
2630+ }
2573 }2631 }
2574 persistMu.Unlock()2632 persistMu.Unlock()
2575 }2633 }
@@ -2666,24 +2724,35 @@ func pickSession(root, cwd string) (string, error) {
2666 // agent's transcript to the session. Used by callers that don't hold2724 // agent's transcript to the session. Used by callers that don't hold
2667 // the persistMu (non-interactive print/json modes which run a single2725 // the persistMu (non-interactive print/json modes which run a single
2668 // turn under their own goroutine).2726 // turn under their own goroutine).
2669-func WriteNewTranscript(ag *core.Agent, sess *core.Session, from int) {2727+func WriteNewTranscript(ag *core.Agent, sess *core.Session, from int) error {
2670- writeNewTranscriptLocked(ag, sess, from)2728+ _, err := writeNewTranscriptLocked(ag, sess, from)
2729+ return err
2671 }2730 }
2672 2731
2673 // writeNewTranscriptLocked is the same as WriteNewTranscript. The2732 // writeNewTranscriptLocked is the same as WriteNewTranscript. The
2674 // suffix marks that interactive callers must hold persistMu when2733 // suffix marks that interactive callers must hold persistMu when
2675 // invoking it so concurrent appends from the agent loop don't race2734 // invoking it so concurrent appends from the agent loop don't race
2676 // with this catch-up flush.2735 // with this catch-up flush.
2677-func writeNewTranscriptLocked(ag *core.Agent, sess *core.Session, from int) {2736+func writeNewTranscriptLocked(ag *core.Agent, sess *core.Session, from int) (next int, err error) {
2737+ next = from
2678 if sess == nil || ag == nil {2738 if sess == nil || ag == nil {
2679- return2739+ return next, nil
2680 }2740 }
2681 msgs := ag.Messages()2741 msgs := ag.Messages()
2682 for i := from; i < len(msgs); i++ {2742 for i := from; i < len(msgs); i++ {
2683- _ = sess.AppendMessage(msgs[i])2743+ if err := sess.AppendMessage(msgs[i]); err != nil {
2744+ return next, fmt.Errorf("append transcript message %d: %w", i, err)
2745+ }
2746+ next = i + 1
2684 }2747 }
2685 cum := ag.Cost()2748 cum := ag.Cost()
2686- _ = sess.AppendUsage(cum, cum)2749+ if err := sess.AppendUsage(cum, cum); err != nil {
2750+ return next, fmt.Errorf("append transcript usage: %w", err)
2751+ }
2752+ if err := sess.Flush(); err != nil {
2753+ return next, fmt.Errorf("flush transcript: %w", err)
2754+ }
2755+ return next, nil
2687 }2756 }
2688 2757
2689 func readAllStdin() (string, error) {2758 func readAllStdin() (string, error) {
M packages/agent/cli_session_test.go +60 -0
@@ -347,3 +347,63 @@ func TestPrepareSessionResumeHonorsExplicitProviderModelFields(t *testing.T) {
347347 })
348348 }
349349 }
350+
351+func TestWriteNewTranscriptTracksPartialAppend(t *testing.T) {
352+ sess, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
353+ if err != nil {
354+ t.Fatal(err)
355+ }
356+ first := provider.Message{
357+ Role: provider.RoleUser,
358+ Content: []provider.Content{provider.TextBlock{Text: "first"}},
359+ }
360+ invalid := provider.Message{
361+ Role: provider.RoleAssistant,
362+ Content: []provider.Content{provider.ToolCallBlock{
363+ ID: "call-1",
364+ Name: "tool",
365+ Arguments: json.RawMessage("{"),
366+ }},
367+ }
368+ ag := core.NewAgent(nil, "model", "", nil)
369+ ag.SetMessages([]provider.Message{first, invalid})
370+ next, err := writeNewTranscriptLocked(ag, sess, 0)
371+ if err == nil {
372+ t.Fatal("writeNewTranscriptLocked succeeded with invalid message")
373+ }
374+ if next != 1 {
375+ t.Fatalf("next persisted message index = %d, want 1", next)
376+ }
377+
378+ second := provider.Message{
379+ Role: provider.RoleAssistant,
380+ Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "tool", Arguments: json.RawMessage(`{}`)}},
381+ }
382+ third := provider.Message{
383+ Role: provider.RoleTool,
384+ Content: []provider.Content{provider.ToolResultBlock{CallID: "call-1", Content: []provider.Content{provider.TextBlock{Text: "done"}}}},
385+ }
386+ ag.SetMessages([]provider.Message{first, second, third})
387+ next, err = writeNewTranscriptLocked(ag, sess, next)
388+ if err != nil {
389+ t.Fatalf("retry transcript: %v", err)
390+ }
391+ if next != 3 {
392+ t.Fatalf("next persisted message index after retry = %d, want 3", next)
393+ }
394+ if err := sess.Close(); err != nil {
395+ t.Fatal(err)
396+ }
397+
398+ reopened, messages, err := core.OpenSession(sess.Path)
399+ if err != nil {
400+ t.Fatalf("reopen session: %v", err)
401+ }
402+ t.Cleanup(func() { _ = reopened.Close() })
403+ if len(messages) != 3 {
404+ t.Fatalf("replayed message count = %d, want 3", len(messages))
405+ }
406+ if repaired := provider.RepairOrphanedToolResults(messages); len(repaired) != len(messages) {
407+ t.Fatalf("replayed transcript lost tool pairing: %#v", messages)
408+ }
409+}
@@ -347,3 +347,63 @@ func TestPrepareSessionResumeHonorsExplicitProviderModelFields(t *testing.T) {
347 })347 })
348 }348 }
349 }349 }
350+
351+func TestWriteNewTranscriptTracksPartialAppend(t *testing.T) {
352+ sess, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
353+ if err != nil {
354+ t.Fatal(err)
355+ }
356+ first := provider.Message{
357+ Role: provider.RoleUser,
358+ Content: []provider.Content{provider.TextBlock{Text: "first"}},
359+ }
360+ invalid := provider.Message{
361+ Role: provider.RoleAssistant,
362+ Content: []provider.Content{provider.ToolCallBlock{
363+ ID: "call-1",
364+ Name: "tool",
365+ Arguments: json.RawMessage("{"),
366+ }},
367+ }
368+ ag := core.NewAgent(nil, "model", "", nil)
369+ ag.SetMessages([]provider.Message{first, invalid})
370+ next, err := writeNewTranscriptLocked(ag, sess, 0)
371+ if err == nil {
372+ t.Fatal("writeNewTranscriptLocked succeeded with invalid message")
373+ }
374+ if next != 1 {
375+ t.Fatalf("next persisted message index = %d, want 1", next)
376+ }
377+
378+ second := provider.Message{
379+ Role: provider.RoleAssistant,
380+ Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "tool", Arguments: json.RawMessage(`{}`)}},
381+ }
382+ third := provider.Message{
383+ Role: provider.RoleTool,
384+ Content: []provider.Content{provider.ToolResultBlock{CallID: "call-1", Content: []provider.Content{provider.TextBlock{Text: "done"}}}},
385+ }
386+ ag.SetMessages([]provider.Message{first, second, third})
387+ next, err = writeNewTranscriptLocked(ag, sess, next)
388+ if err != nil {
389+ t.Fatalf("retry transcript: %v", err)
390+ }
391+ if next != 3 {
392+ t.Fatalf("next persisted message index after retry = %d, want 3", next)
393+ }
394+ if err := sess.Close(); err != nil {
395+ t.Fatal(err)
396+ }
397+
398+ reopened, messages, err := core.OpenSession(sess.Path)
399+ if err != nil {
400+ t.Fatalf("reopen session: %v", err)
401+ }
402+ t.Cleanup(func() { _ = reopened.Close() })
403+ if len(messages) != 3 {
404+ t.Fatalf("replayed message count = %d, want 3", len(messages))
405+ }
406+ if repaired := provider.RepairOrphanedToolResults(messages); len(repaired) != len(messages) {
407+ t.Fatalf("replayed transcript lost tool pairing: %#v", messages)
408+ }
409+}
M packages/agent/modes/auto_compact_test.go +4 -4
@@ -28,8 +28,8 @@ func TestNormalizeAutoCompactThreshold(t *testing.T) {
2828
2929 for _, tt := range tests {
3030 t.Run(tt.name, func(t *testing.T) {
31- if got := normalizeAutoCompactThreshold(tt.value); got != tt.want {
32- t.Fatalf("normalizeAutoCompactThreshold(%v) = %d, want %d", tt.value, got, tt.want)
31+ if got := NormalizeAutoCompactThreshold(tt.value); got != tt.want {
32+ t.Fatalf("NormalizeAutoCompactThreshold(%v) = %d, want %d", tt.value, got, tt.want)
3333 }
3434 })
3535 }
@@ -353,8 +353,8 @@ func TestShouldAutoCompactUsesConfiguredThreshold(t *testing.T) {
353353
354354 for _, tt := range tests {
355355 t.Run(tt.name, func(t *testing.T) {
356- if got := shouldAutoCompact(tt.used, tt.window, tt.threshold); got != tt.want {
357- t.Fatalf("shouldAutoCompact(%d, %d, %d) = %t, want %t", tt.used, tt.window, tt.threshold, got, tt.want)
356+ if got := ShouldAutoCompact(tt.used, tt.window, tt.threshold); got != tt.want {
357+ t.Fatalf("ShouldAutoCompact(%d, %d, %d) = %t, want %t", tt.used, tt.window, tt.threshold, got, tt.want)
358358 }
359359 })
360360 }
@@ -28,8 +28,8 @@ func TestNormalizeAutoCompactThreshold(t *testing.T) {
28 28
29 for _, tt := range tests {29 for _, tt := range tests {
30 t.Run(tt.name, func(t *testing.T) {30 t.Run(tt.name, func(t *testing.T) {
31- if got := normalizeAutoCompactThreshold(tt.value); got != tt.want {31+ if got := NormalizeAutoCompactThreshold(tt.value); got != tt.want {
32- t.Fatalf("normalizeAutoCompactThreshold(%v) = %d, want %d", tt.value, got, tt.want)32+ t.Fatalf("NormalizeAutoCompactThreshold(%v) = %d, want %d", tt.value, got, tt.want)
33 }33 }
34 })34 })
35 }35 }
@@ -353,8 +353,8 @@ func TestShouldAutoCompactUsesConfiguredThreshold(t *testing.T) {
353 353
354 for _, tt := range tests {354 for _, tt := range tests {
355 t.Run(tt.name, func(t *testing.T) {355 t.Run(tt.name, func(t *testing.T) {
356- if got := shouldAutoCompact(tt.used, tt.window, tt.threshold); got != tt.want {356+ if got := ShouldAutoCompact(tt.used, tt.window, tt.threshold); got != tt.want {
357- t.Fatalf("shouldAutoCompact(%d, %d, %d) = %t, want %t", tt.used, tt.window, tt.threshold, got, tt.want)357+ t.Fatalf("ShouldAutoCompact(%d, %d, %d) = %t, want %t", tt.used, tt.window, tt.threshold, got, tt.want)
358 }358 }
359 })359 })
360 }360 }
M packages/agent/modes/context_recovery.go +86 -19
@@ -17,6 +17,13 @@ type ContextRecoveryOptions struct {
1717 // transcript and prevents the continuation request.
1818 PersistCompaction func([]provider.Message) error
1919
20+ // ContextWindow and AutoCompactThreshold enable proactive compaction before
21+ // Prompt. The threshold uses the same supported percentage values and 85%
22+ // default as interactive mode; zero disables it. Context usage includes
23+ // uncached input, cache reads, and cache writes from the latest request.
24+ ContextWindow int
25+ AutoCompactThreshold *int
26+
2027 // SuppressInitialOverflowEvent hides the recoverable first-attempt
2128 // EvTurnEnd overflow from sinks whose protocol cannot retract a terminal
2229 // error frame, such as JSONL. A retry overflow is never suppressed.
@@ -31,19 +38,54 @@ type ContextRecoveryResult struct {
3138 Compacted bool
3239 }
3340
34-// PromptWithContextRecovery appends prompt and runs it once. If that provider
35-// request exceeds its context window, it compacts the existing transcript and
36-// continues the already-appended prompt exactly once. Cancellation, a
37-// one-message transcript, a compaction failure, and a second overflow remain
38-// terminal outcomes.
41+// PromptWithContextRecovery proactively compacts a transcript whose latest
42+// request reached the configured context percentage, then appends prompt and
43+// runs it once. If that provider request still exceeds its context window, it
44+// compacts the existing transcript and continues the already-appended prompt
45+// exactly once. Cancellation, a compaction failure, and a second overflow
46+// remain terminal outcomes.
3947 func PromptWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, sink func(core.AgentEvent), options ContextRecoveryOptions) (result ContextRecoveryResult, err error) {
4048 result.OutputStart = len(ag.Messages())
4149 if sink == nil {
4250 sink = func(core.AgentEvent) {}
4351 }
4452
53+ usage := ag.LastTurnUsage()
54+ contextUsed := usage.InputTokens + usage.CacheReadTokens + usage.CacheWriteTokens
55+ threshold := NormalizeAutoCompactThreshold(options.AutoCompactThreshold)
56+ proactiveSummaryOverflow := false
57+ if ShouldAutoCompact(contextUsed, options.ContextWindow, threshold) {
58+ messages := ag.Messages()
59+ // A sole transcript message is commonly the worker's only task.
60+ // Replacing it before it has run loses the exact instruction.
61+ if len(messages) > 1 {
62+ if err := ctx.Err(); err != nil {
63+ return result, err
64+ }
65+ compacted, compactErr := compactContext(ctx, ag, messages, options.PersistCompaction)
66+ if compactErr != nil {
67+ if ctxErr := ctx.Err(); ctxErr != nil {
68+ return result, ctxErr
69+ }
70+ if !provider.IsContextOverflowError(compactErr) {
71+ return result, fmt.Errorf("proactively compact transcript: %w", compactErr)
72+ }
73+ // The summary request can itself exceed a provider limit. The
74+ // transcript is unchanged, so make one normal prompt attempt. If
75+ // that also overflows, do not repeat the doomed summary request.
76+ proactiveSummaryOverflow = true
77+ } else {
78+ result.OutputStart = len(compacted)
79+ result.Compacted = true
80+ if err := ctx.Err(); err != nil {
81+ return result, err
82+ }
83+ }
84+ }
85+ }
86+
4587 firstSink := sink
46- if options.SuppressInitialOverflowEvent {
88+ if options.SuppressInitialOverflowEvent && !proactiveSummaryOverflow {
4789 firstSink = func(event core.AgentEvent) {
4890 if turnEnd, ok := event.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) {
4991 return
@@ -52,33 +94,58 @@ func PromptWithContextRecovery(ctx context.Context, ag *core.Agent, prompt strin
5294 }
5395 }
5496
55- if err = ag.Prompt(ctx, prompt, images, firstSink); err == nil || ctx.Err() != nil || !provider.IsContextOverflowError(err) {
97+ if err = ag.Prompt(ctx, prompt, images, firstSink); err == nil || ctx.Err() != nil || !provider.IsContextOverflowError(err) || proactiveSummaryOverflow {
5698 return result, err
5799 }
58100
101+ if ctxErr := ctx.Err(); ctxErr != nil {
102+ return result, ctxErr
103+ }
59104 messages := ag.Messages()
60105 if len(messages) <= 1 {
61106 // The failed prompt is the only message. Summarizing it would replace
62107 // the user's task with an LLM-generated paraphrase.
63108 return result, err
64109 }
65- keepTail := min(4, len(messages)-1)
66- if _, compactErr := ag.Compact(ctx, keepTail, nil); compactErr != nil {
110+ compacted, compactErr := compactContext(ctx, ag, messages, options.PersistCompaction)
111+ if compactErr != nil {
67112 return result, fmt.Errorf("compact transcript after initial overflow: %w", compactErr)
68113 }
69114
115+ result.OutputStart = len(compacted)
116+ result.Compacted = true
117+ if ctxErr := ctx.Err(); ctxErr != nil {
118+ return result, ctxErr
119+ }
120+ return result, ag.Continue(ctx, sink)
121+}
122+
123+func compactContext(ctx context.Context, ag *core.Agent, messages []provider.Message, persist func([]provider.Message) error) ([]provider.Message, error) {
124+ if err := ctx.Err(); err != nil {
125+ return nil, err
126+ }
127+ keepTail := min(4, len(messages)-1)
128+ if _, err := ag.Compact(ctx, keepTail, nil); err != nil {
129+ return nil, err
130+ }
131+
70132 compacted := ag.Messages()
71- if options.PersistCompaction != nil {
72- if persistErr := options.PersistCompaction(compacted); persistErr != nil {
73- // The session did not receive the compaction checkpoint, so restore
74- // the original history plus the already-appended prompt rather than
75- // let a later transcript flush make the session inconsistent.
133+ if err := ctx.Err(); err != nil {
134+ ag.SetMessages(messages)
135+ return nil, err
136+ }
137+ if persist != nil {
138+ if err := ctx.Err(); err != nil {
76139 ag.SetMessages(messages)
77- return result, fmt.Errorf("persist compacted transcript: %w", persistErr)
140+ return nil, err
141+ }
142+ if err := persist(compacted); err != nil {
143+ // The session did not receive the compaction checkpoint. Restore the
144+ // exact pre-compaction transcript so a later suffix flush cannot mix
145+ // the old durable generation with the new in-memory generation.
146+ ag.SetMessages(messages)
147+ return nil, fmt.Errorf("persist compacted transcript: %w", err)
78148 }
79149 }
80-
81- result.OutputStart = len(compacted)
82- result.Compacted = true
83- return result, ag.Continue(ctx, sink)
150+ return compacted, nil
84151 }
@@ -17,6 +17,13 @@ type ContextRecoveryOptions struct {
17 // transcript and prevents the continuation request.17 // transcript and prevents the continuation request.
18 PersistCompaction func([]provider.Message) error18 PersistCompaction func([]provider.Message) error
19 19
20+ // ContextWindow and AutoCompactThreshold enable proactive compaction before
21+ // Prompt. The threshold uses the same supported percentage values and 85%
22+ // default as interactive mode; zero disables it. Context usage includes
23+ // uncached input, cache reads, and cache writes from the latest request.
24+ ContextWindow int
25+ AutoCompactThreshold *int
26+
20 // SuppressInitialOverflowEvent hides the recoverable first-attempt27 // SuppressInitialOverflowEvent hides the recoverable first-attempt
21 // EvTurnEnd overflow from sinks whose protocol cannot retract a terminal28 // EvTurnEnd overflow from sinks whose protocol cannot retract a terminal
22 // error frame, such as JSONL. A retry overflow is never suppressed.29 // error frame, such as JSONL. A retry overflow is never suppressed.
@@ -31,19 +38,54 @@ type ContextRecoveryResult struct {
31 Compacted bool38 Compacted bool
32 }39 }
33 40
34-// PromptWithContextRecovery appends prompt and runs it once. If that provider41+// PromptWithContextRecovery proactively compacts a transcript whose latest
35-// request exceeds its context window, it compacts the existing transcript and42+// request reached the configured context percentage, then appends prompt and
36-// continues the already-appended prompt exactly once. Cancellation, a43+// runs it once. If that provider request still exceeds its context window, it
37-// one-message transcript, a compaction failure, and a second overflow remain44+// compacts the existing transcript and continues the already-appended prompt
38-// terminal outcomes.45+// exactly once. Cancellation, a compaction failure, and a second overflow
46+// remain terminal outcomes.
39 func PromptWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, sink func(core.AgentEvent), options ContextRecoveryOptions) (result ContextRecoveryResult, err error) {47 func PromptWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, sink func(core.AgentEvent), options ContextRecoveryOptions) (result ContextRecoveryResult, err error) {
40 result.OutputStart = len(ag.Messages())48 result.OutputStart = len(ag.Messages())
41 if sink == nil {49 if sink == nil {
42 sink = func(core.AgentEvent) {}50 sink = func(core.AgentEvent) {}
43 }51 }
44 52
53+ usage := ag.LastTurnUsage()
54+ contextUsed := usage.InputTokens + usage.CacheReadTokens + usage.CacheWriteTokens
55+ threshold := NormalizeAutoCompactThreshold(options.AutoCompactThreshold)
56+ proactiveSummaryOverflow := false
57+ if ShouldAutoCompact(contextUsed, options.ContextWindow, threshold) {
58+ messages := ag.Messages()
59+ // A sole transcript message is commonly the worker's only task.
60+ // Replacing it before it has run loses the exact instruction.
61+ if len(messages) > 1 {
62+ if err := ctx.Err(); err != nil {
63+ return result, err
64+ }
65+ compacted, compactErr := compactContext(ctx, ag, messages, options.PersistCompaction)
66+ if compactErr != nil {
67+ if ctxErr := ctx.Err(); ctxErr != nil {
68+ return result, ctxErr
69+ }
70+ if !provider.IsContextOverflowError(compactErr) {
71+ return result, fmt.Errorf("proactively compact transcript: %w", compactErr)
72+ }
73+ // The summary request can itself exceed a provider limit. The
74+ // transcript is unchanged, so make one normal prompt attempt. If
75+ // that also overflows, do not repeat the doomed summary request.
76+ proactiveSummaryOverflow = true
77+ } else {
78+ result.OutputStart = len(compacted)
79+ result.Compacted = true
80+ if err := ctx.Err(); err != nil {
81+ return result, err
82+ }
83+ }
84+ }
85+ }
86+
45 firstSink := sink87 firstSink := sink
46- if options.SuppressInitialOverflowEvent {88+ if options.SuppressInitialOverflowEvent && !proactiveSummaryOverflow {
47 firstSink = func(event core.AgentEvent) {89 firstSink = func(event core.AgentEvent) {
48 if turnEnd, ok := event.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) {90 if turnEnd, ok := event.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) {
49 return91 return
@@ -52,33 +94,58 @@ func PromptWithContextRecovery(ctx context.Context, ag *core.Agent, prompt strin
52 }94 }
53 }95 }
54 96
55- if err = ag.Prompt(ctx, prompt, images, firstSink); err == nil || ctx.Err() != nil || !provider.IsContextOverflowError(err) {97+ if err = ag.Prompt(ctx, prompt, images, firstSink); err == nil || ctx.Err() != nil || !provider.IsContextOverflowError(err) || proactiveSummaryOverflow {
56 return result, err98 return result, err
57 }99 }
58 100
101+ if ctxErr := ctx.Err(); ctxErr != nil {
102+ return result, ctxErr
103+ }
59 messages := ag.Messages()104 messages := ag.Messages()
60 if len(messages) <= 1 {105 if len(messages) <= 1 {
61 // The failed prompt is the only message. Summarizing it would replace106 // The failed prompt is the only message. Summarizing it would replace
62 // the user's task with an LLM-generated paraphrase.107 // the user's task with an LLM-generated paraphrase.
63 return result, err108 return result, err
64 }109 }
65- keepTail := min(4, len(messages)-1)110+ compacted, compactErr := compactContext(ctx, ag, messages, options.PersistCompaction)
66- if _, compactErr := ag.Compact(ctx, keepTail, nil); compactErr != nil {111+ if compactErr != nil {
67 return result, fmt.Errorf("compact transcript after initial overflow: %w", compactErr)112 return result, fmt.Errorf("compact transcript after initial overflow: %w", compactErr)
68 }113 }
69 114
115+ result.OutputStart = len(compacted)
116+ result.Compacted = true
117+ if ctxErr := ctx.Err(); ctxErr != nil {
118+ return result, ctxErr
119+ }
120+ return result, ag.Continue(ctx, sink)
121+}
122+
123+func compactContext(ctx context.Context, ag *core.Agent, messages []provider.Message, persist func([]provider.Message) error) ([]provider.Message, error) {
124+ if err := ctx.Err(); err != nil {
125+ return nil, err
126+ }
127+ keepTail := min(4, len(messages)-1)
128+ if _, err := ag.Compact(ctx, keepTail, nil); err != nil {
129+ return nil, err
130+ }
131+
70 compacted := ag.Messages()132 compacted := ag.Messages()
71- if options.PersistCompaction != nil {133+ if err := ctx.Err(); err != nil {
72- if persistErr := options.PersistCompaction(compacted); persistErr != nil {134+ ag.SetMessages(messages)
73- // The session did not receive the compaction checkpoint, so restore135+ return nil, err
74- // the original history plus the already-appended prompt rather than136+ }
75- // let a later transcript flush make the session inconsistent.137+ if persist != nil {
138+ if err := ctx.Err(); err != nil {
76 ag.SetMessages(messages)139 ag.SetMessages(messages)
77- return result, fmt.Errorf("persist compacted transcript: %w", persistErr)140+ return nil, err
141+ }
142+ if err := persist(compacted); err != nil {
143+ // The session did not receive the compaction checkpoint. Restore the
144+ // exact pre-compaction transcript so a later suffix flush cannot mix
145+ // the old durable generation with the new in-memory generation.
146+ ag.SetMessages(messages)
147+ return nil, fmt.Errorf("persist compacted transcript: %w", err)
78 }148 }
79 }149 }
80-150+ return compacted, nil
81- result.OutputStart = len(compacted)
82- result.Compacted = true
83- return result, ag.Continue(ctx, sink)
84 }151 }
M packages/agent/modes/context_recovery_test.go +307 -0
@@ -6,6 +6,8 @@ import (
66 "context"
77 "encoding/json"
88 "errors"
9+ "reflect"
10+ "strings"
911 "sync"
1012 "testing"
1113
@@ -22,6 +24,311 @@ type hostRecoveryClient struct {
2224
2325 func (c *hostRecoveryClient) Name() string { return "host-context-recovery-test" }
2426
27+type proactiveContextClient struct {
28+ mu sync.Mutex
29+ requests []provider.Request
30+ summaryErr error
31+ onSummary func()
32+ normalOverflow bool
33+}
34+
35+func (c *proactiveContextClient) Name() string { return "proactive-context-test" }
36+
37+func (c *proactiveContextClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
38+ c.mu.Lock()
39+ c.requests = append(c.requests, req)
40+ c.mu.Unlock()
41+
42+ events := make(chan provider.Event, 2)
43+ go func() {
44+ defer close(events)
45+ if strings.Contains(req.System, "context summarization assistant") {
46+ if c.onSummary != nil {
47+ c.onSummary()
48+ }
49+ if c.summaryErr != nil {
50+ events <- provider.EventDone{Stop: provider.StopError, Err: c.summaryErr}
51+ return
52+ }
53+ events <- provider.EventTextDelta{Delta: "condensed history"}
54+ events <- provider.EventDone{Stop: provider.StopEnd}
55+ return
56+ }
57+ if c.normalOverflow {
58+ events <- provider.EventDone{Stop: provider.StopError, Err: errors.New("context window exceeded")}
59+ return
60+ }
61+ events <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
62+ Role: provider.RoleAssistant,
63+ Content: []provider.Content{provider.TextBlock{Text: "completed"}},
64+ }}
65+ }()
66+ return events, nil
67+}
68+
69+func TestPromptWithContextRecoveryProactivelyCompactsCacheOnlyUsage(t *testing.T) {
70+ client := &proactiveContextClient{}
71+ agent := core.NewAgent(client, "model", "system", nil)
72+ history := []provider.Message{
73+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
74+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
75+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
76+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
77+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "five"}}},
78+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "six"}}},
79+ }
80+ agent.SetMessages(history)
81+ agent.SeedLastTurnUsage(provider.Usage{CacheReadTokens: 85})
82+
83+ var persisted [][]provider.Message
84+ result, err := PromptWithContextRecovery(context.Background(), agent, "continue", nil, nil, ContextRecoveryOptions{
85+ ContextWindow: 100,
86+ PersistCompaction: func(messages []provider.Message) error {
87+ persisted = append(persisted, messages)
88+ return nil
89+ },
90+ })
91+ if err != nil {
92+ t.Fatalf("PromptWithContextRecovery: %v", err)
93+ }
94+ if !result.Compacted || len(persisted) != 1 {
95+ t.Fatalf("compacted/persisted = %t/%d, want true/1", result.Compacted, len(persisted))
96+ }
97+ if got := recoveredAssistantText(agent.Messages()[result.OutputStart:]); got != "completed" {
98+ t.Fatalf("output after compacted boundary = %q, want completed", got)
99+ }
100+
101+ client.mu.Lock()
102+ requests := append([]provider.Request(nil), client.requests...)
103+ client.mu.Unlock()
104+ if len(requests) != 2 {
105+ t.Fatalf("provider requests = %d, want compaction then prompt", len(requests))
106+ }
107+ if !recoveryRequestContainsText(requests[0], "<conversation>") || !recoveryRequestContainsText(requests[0], "one") {
108+ t.Fatalf("first request was not compaction over old history: %#v", requests[0])
109+ }
110+ if got := requestUserTextCount(requests[1], "continue"); got != 1 {
111+ t.Fatalf("prompt count after proactive compaction = %d, want 1", got)
112+ }
113+}
114+
115+func TestPromptWithContextRecoveryHonorsDisabledThreshold(t *testing.T) {
116+ client := &proactiveContextClient{}
117+ agent := core.NewAgent(client, "model", "system", nil)
118+ agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "history"}}}})
119+ agent.SeedLastTurnUsage(provider.Usage{InputTokens: 100, CacheReadTokens: 100})
120+ disabled := 0
121+
122+ result, err := PromptWithContextRecovery(context.Background(), agent, "continue", nil, nil, ContextRecoveryOptions{
123+ ContextWindow: 100,
124+ AutoCompactThreshold: &disabled,
125+ })
126+ if err != nil {
127+ t.Fatalf("PromptWithContextRecovery: %v", err)
128+ }
129+ if result.Compacted {
130+ t.Fatal("disabled threshold unexpectedly compacted")
131+ }
132+ client.mu.Lock()
133+ requests := append([]provider.Request(nil), client.requests...)
134+ client.mu.Unlock()
135+ if len(requests) != 1 || requestUserTextCount(requests[0], "continue") != 1 {
136+ t.Fatalf("requests with disabled threshold = %#v, want one direct prompt", requests)
137+ }
138+}
139+
140+func TestPromptWithContextRecoveryRestoresProactiveCompactionOnPersistenceFailure(t *testing.T) {
141+ client := &proactiveContextClient{}
142+ agent := core.NewAgent(client, "model", "system", nil)
143+ history := []provider.Message{
144+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "original"}}},
145+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "answer"}}},
146+ }
147+ agent.SetMessages(history)
148+ agent.SeedLastTurnUsage(provider.Usage{CacheWriteTokens: 90})
149+
150+ _, err := PromptWithContextRecovery(context.Background(), agent, "must not send", nil, nil, ContextRecoveryOptions{
151+ ContextWindow: 100,
152+ PersistCompaction: func([]provider.Message) error {
153+ return errors.New("disk full")
154+ },
155+ })
156+ if err == nil || !strings.Contains(err.Error(), "persist compacted transcript: disk full") {
157+ t.Fatalf("error = %v, want persistence failure", err)
158+ }
159+ if got := agent.Messages(); !reflect.DeepEqual(got, history) {
160+ t.Fatalf("transcript after persistence failure = %#v, want %#v", got, history)
161+ }
162+ client.mu.Lock()
163+ requestCount := len(client.requests)
164+ client.mu.Unlock()
165+ if requestCount != 1 {
166+ t.Fatalf("provider requests = %d, want compaction only", requestCount)
167+ }
168+}
169+
170+func TestPromptWithContextRecoveryDoesNotProactivelyCompactSoleTask(t *testing.T) {
171+ client := &proactiveContextClient{}
172+ agent := core.NewAgent(client, "model", "system", nil)
173+ task := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "exact sole task"}}}
174+ agent.SetMessages([]provider.Message{task})
175+ agent.SeedLastTurnUsage(provider.Usage{CacheReadTokens: 95})
176+
177+ result, err := PromptWithContextRecovery(context.Background(), agent, "continue", nil, nil, ContextRecoveryOptions{ContextWindow: 100})
178+ if err != nil {
179+ t.Fatalf("PromptWithContextRecovery: %v", err)
180+ }
181+ if result.Compacted {
182+ t.Fatal("sole task was proactively compacted")
183+ }
184+ client.mu.Lock()
185+ requests := append([]provider.Request(nil), client.requests...)
186+ client.mu.Unlock()
187+ if len(requests) != 1 || !recoveryRequestContainsText(requests[0], "exact sole task") {
188+ t.Fatalf("requests = %#v, want one direct request retaining the sole task", requests)
189+ }
190+}
191+
192+func TestPromptWithContextRecoveryFallsBackWhenProactiveSummaryOverflows(t *testing.T) {
193+ client := &proactiveContextClient{summaryErr: errors.New("context window exceeded while summarizing")}
194+ agent := core.NewAgent(client, "model", "system", nil)
195+ history := []provider.Message{
196+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "history"}}},
197+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "answer"}}},
198+ }
199+ agent.SetMessages(history)
200+ agent.SeedLastTurnUsage(provider.Usage{InputTokens: 90})
201+
202+ result, err := PromptWithContextRecovery(context.Background(), agent, "same task", nil, nil, ContextRecoveryOptions{ContextWindow: 100})
203+ if err != nil {
204+ t.Fatalf("PromptWithContextRecovery: %v", err)
205+ }
206+ if result.Compacted {
207+ t.Fatal("overflowing proactive summary reported compaction")
208+ }
209+ client.mu.Lock()
210+ requests := append([]provider.Request(nil), client.requests...)
211+ client.mu.Unlock()
212+ if len(requests) != 2 {
213+ t.Fatalf("provider requests = %d, want one summary and one normal prompt", len(requests))
214+ }
215+ if got := requestUserTextCount(requests[1], "same task"); got != 1 {
216+ t.Fatalf("normal recovery prompt count = %d, want 1", got)
217+ }
218+}
219+
220+func TestPromptWithContextRecoveryStopsBeforeContinuationWhenPersistenceCancels(t *testing.T) {
221+ ctx, cancel := context.WithCancel(context.Background())
222+ client := &proactiveContextClient{}
223+ agent := core.NewAgent(client, "model", "system", nil)
224+ history := []provider.Message{
225+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "history"}}},
226+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "answer"}}},
227+ }
228+ agent.SetMessages(history)
229+ agent.SeedLastTurnUsage(provider.Usage{CacheWriteTokens: 90})
230+ persisted := 0
231+
232+ result, err := PromptWithContextRecovery(ctx, agent, "must not run", nil, nil, ContextRecoveryOptions{
233+ ContextWindow: 100,
234+ PersistCompaction: func([]provider.Message) error {
235+ persisted++
236+ cancel()
237+ return nil
238+ },
239+ })
240+ if !errors.Is(err, context.Canceled) {
241+ t.Fatalf("error = %v, want context cancellation", err)
242+ }
243+ if !result.Compacted || persisted != 1 {
244+ t.Fatalf("compaction result/persistence = %v/%d, want true/1", result.Compacted, persisted)
245+ }
246+ client.mu.Lock()
247+ requestCount := len(client.requests)
248+ client.mu.Unlock()
249+ if requestCount != 1 {
250+ t.Fatalf("provider requests = %d, want summary only", requestCount)
251+ }
252+}
253+
254+func TestPromptWithContextRecoveryRollsBackWhenCanceledAfterSummary(t *testing.T) {
255+ ctx, cancel := context.WithCancel(context.Background())
256+ client := &proactiveContextClient{onSummary: cancel}
257+ agent := core.NewAgent(client, "model", "system", nil)
258+ history := []provider.Message{
259+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "history"}}},
260+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "answer"}}},
261+ }
262+ agent.SetMessages(history)
263+ agent.SeedLastTurnUsage(provider.Usage{CacheWriteTokens: 90})
264+ persisted := 0
265+
266+ _, err := PromptWithContextRecovery(ctx, agent, "must not run", nil, nil, ContextRecoveryOptions{
267+ ContextWindow: 100,
268+ PersistCompaction: func([]provider.Message) error {
269+ persisted++
270+ return nil
271+ },
272+ })
273+ if !errors.Is(err, context.Canceled) {
274+ t.Fatalf("error = %v, want context cancellation", err)
275+ }
276+ if persisted != 0 {
277+ t.Fatalf("persistence calls = %d, want 0", persisted)
278+ }
279+ if got := agent.Messages(); !reflect.DeepEqual(got, history) {
280+ t.Fatalf("transcript after cancellation = %#v, want %#v", got, history)
281+ }
282+ client.mu.Lock()
283+ requestCount := len(client.requests)
284+ client.mu.Unlock()
285+ if requestCount != 1 {
286+ t.Fatalf("provider requests = %d, want summary only", requestCount)
287+ }
288+}
289+
290+func TestPromptWithContextRecoveryRepairsToolPairAtCompactionBoundary(t *testing.T) {
291+ client := &proactiveContextClient{}
292+ agent := core.NewAgent(client, "model", "system", nil)
293+ agent.SetMessages([]provider.Message{
294+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "old"}}},
295+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "split-call", Name: "read"}}},
296+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{CallID: "split-call", Content: []provider.Content{provider.TextBlock{Text: "result"}}}}},
297+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "after tool"}}},
298+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "next"}}},
299+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "ready"}}},
300+ })
301+ agent.SeedLastTurnUsage(provider.Usage{InputTokens: 90})
302+
303+ if _, err := PromptWithContextRecovery(context.Background(), agent, "continue", nil, nil, ContextRecoveryOptions{ContextWindow: 100}); err != nil {
304+ t.Fatalf("PromptWithContextRecovery: %v", err)
305+ }
306+ client.mu.Lock()
307+ requests := append([]provider.Request(nil), client.requests...)
308+ client.mu.Unlock()
309+ if len(requests) != 2 {
310+ t.Fatalf("provider requests = %d, want summary then continuation", len(requests))
311+ }
312+ for _, message := range requests[1].Messages {
313+ for _, content := range message.Content {
314+ if result, ok := content.(provider.ToolResultBlock); ok && result.CallID == "split-call" {
315+ t.Fatalf("continuation contains orphaned tool result: %#v", requests[1].Messages)
316+ }
317+ }
318+ }
319+}
320+
321+func recoveryRequestContainsText(req provider.Request, want string) bool {
322+ for _, message := range req.Messages {
323+ for _, content := range message.Content {
324+ if text, ok := content.(provider.TextBlock); ok && strings.Contains(text.Text, want) {
325+ return true
326+ }
327+ }
328+ }
329+ return false
330+}
331+
25332 func (c *hostRecoveryClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
26333 c.mu.Lock()
27334 c.calls++
@@ -6,6 +6,8 @@ import (
6 "context"6 "context"
7 "encoding/json"7 "encoding/json"
8 "errors"8 "errors"
9+ "reflect"
10+ "strings"
9 "sync"11 "sync"
10 "testing"12 "testing"
11 13
@@ -22,6 +24,311 @@ type hostRecoveryClient struct {
22 24
23 func (c *hostRecoveryClient) Name() string { return "host-context-recovery-test" }25 func (c *hostRecoveryClient) Name() string { return "host-context-recovery-test" }
24 26
27+type proactiveContextClient struct {
28+ mu sync.Mutex
29+ requests []provider.Request
30+ summaryErr error
31+ onSummary func()
32+ normalOverflow bool
33+}
34+
35+func (c *proactiveContextClient) Name() string { return "proactive-context-test" }
36+
37+func (c *proactiveContextClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
38+ c.mu.Lock()
39+ c.requests = append(c.requests, req)
40+ c.mu.Unlock()
41+
42+ events := make(chan provider.Event, 2)
43+ go func() {
44+ defer close(events)
45+ if strings.Contains(req.System, "context summarization assistant") {
46+ if c.onSummary != nil {
47+ c.onSummary()
48+ }
49+ if c.summaryErr != nil {
50+ events <- provider.EventDone{Stop: provider.StopError, Err: c.summaryErr}
51+ return
52+ }
53+ events <- provider.EventTextDelta{Delta: "condensed history"}
54+ events <- provider.EventDone{Stop: provider.StopEnd}
55+ return
56+ }
57+ if c.normalOverflow {
58+ events <- provider.EventDone{Stop: provider.StopError, Err: errors.New("context window exceeded")}
59+ return
60+ }
61+ events <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
62+ Role: provider.RoleAssistant,
63+ Content: []provider.Content{provider.TextBlock{Text: "completed"}},
64+ }}
65+ }()
66+ return events, nil
67+}
68+
69+func TestPromptWithContextRecoveryProactivelyCompactsCacheOnlyUsage(t *testing.T) {
70+ client := &proactiveContextClient{}
71+ agent := core.NewAgent(client, "model", "system", nil)
72+ history := []provider.Message{
73+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
74+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
75+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
76+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
77+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "five"}}},
78+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "six"}}},
79+ }
80+ agent.SetMessages(history)
81+ agent.SeedLastTurnUsage(provider.Usage{CacheReadTokens: 85})
82+
83+ var persisted [][]provider.Message
84+ result, err := PromptWithContextRecovery(context.Background(), agent, "continue", nil, nil, ContextRecoveryOptions{
85+ ContextWindow: 100,
86+ PersistCompaction: func(messages []provider.Message) error {
87+ persisted = append(persisted, messages)
88+ return nil
89+ },
90+ })
91+ if err != nil {
92+ t.Fatalf("PromptWithContextRecovery: %v", err)
93+ }
94+ if !result.Compacted || len(persisted) != 1 {
95+ t.Fatalf("compacted/persisted = %t/%d, want true/1", result.Compacted, len(persisted))
96+ }
97+ if got := recoveredAssistantText(agent.Messages()[result.OutputStart:]); got != "completed" {
98+ t.Fatalf("output after compacted boundary = %q, want completed", got)
99+ }
100+
101+ client.mu.Lock()
102+ requests := append([]provider.Request(nil), client.requests...)
103+ client.mu.Unlock()
104+ if len(requests) != 2 {
105+ t.Fatalf("provider requests = %d, want compaction then prompt", len(requests))
106+ }
107+ if !recoveryRequestContainsText(requests[0], "<conversation>") || !recoveryRequestContainsText(requests[0], "one") {
108+ t.Fatalf("first request was not compaction over old history: %#v", requests[0])
109+ }
110+ if got := requestUserTextCount(requests[1], "continue"); got != 1 {
111+ t.Fatalf("prompt count after proactive compaction = %d, want 1", got)
112+ }
113+}
114+
115+func TestPromptWithContextRecoveryHonorsDisabledThreshold(t *testing.T) {
116+ client := &proactiveContextClient{}
117+ agent := core.NewAgent(client, "model", "system", nil)
118+ agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "history"}}}})
119+ agent.SeedLastTurnUsage(provider.Usage{InputTokens: 100, CacheReadTokens: 100})
120+ disabled := 0
121+
122+ result, err := PromptWithContextRecovery(context.Background(), agent, "continue", nil, nil, ContextRecoveryOptions{
123+ ContextWindow: 100,
124+ AutoCompactThreshold: &disabled,
125+ })
126+ if err != nil {
127+ t.Fatalf("PromptWithContextRecovery: %v", err)
128+ }
129+ if result.Compacted {
130+ t.Fatal("disabled threshold unexpectedly compacted")
131+ }
132+ client.mu.Lock()
133+ requests := append([]provider.Request(nil), client.requests...)
134+ client.mu.Unlock()
135+ if len(requests) != 1 || requestUserTextCount(requests[0], "continue") != 1 {
136+ t.Fatalf("requests with disabled threshold = %#v, want one direct prompt", requests)
137+ }
138+}
139+
140+func TestPromptWithContextRecoveryRestoresProactiveCompactionOnPersistenceFailure(t *testing.T) {
141+ client := &proactiveContextClient{}
142+ agent := core.NewAgent(client, "model", "system", nil)
143+ history := []provider.Message{
144+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "original"}}},
145+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "answer"}}},
146+ }
147+ agent.SetMessages(history)
148+ agent.SeedLastTurnUsage(provider.Usage{CacheWriteTokens: 90})
149+
150+ _, err := PromptWithContextRecovery(context.Background(), agent, "must not send", nil, nil, ContextRecoveryOptions{
151+ ContextWindow: 100,
152+ PersistCompaction: func([]provider.Message) error {
153+ return errors.New("disk full")
154+ },
155+ })
156+ if err == nil || !strings.Contains(err.Error(), "persist compacted transcript: disk full") {
157+ t.Fatalf("error = %v, want persistence failure", err)
158+ }
159+ if got := agent.Messages(); !reflect.DeepEqual(got, history) {
160+ t.Fatalf("transcript after persistence failure = %#v, want %#v", got, history)
161+ }
162+ client.mu.Lock()
163+ requestCount := len(client.requests)
164+ client.mu.Unlock()
165+ if requestCount != 1 {
166+ t.Fatalf("provider requests = %d, want compaction only", requestCount)
167+ }
168+}
169+
170+func TestPromptWithContextRecoveryDoesNotProactivelyCompactSoleTask(t *testing.T) {
171+ client := &proactiveContextClient{}
172+ agent := core.NewAgent(client, "model", "system", nil)
173+ task := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "exact sole task"}}}
174+ agent.SetMessages([]provider.Message{task})
175+ agent.SeedLastTurnUsage(provider.Usage{CacheReadTokens: 95})
176+
177+ result, err := PromptWithContextRecovery(context.Background(), agent, "continue", nil, nil, ContextRecoveryOptions{ContextWindow: 100})
178+ if err != nil {
179+ t.Fatalf("PromptWithContextRecovery: %v", err)
180+ }
181+ if result.Compacted {
182+ t.Fatal("sole task was proactively compacted")
183+ }
184+ client.mu.Lock()
185+ requests := append([]provider.Request(nil), client.requests...)
186+ client.mu.Unlock()
187+ if len(requests) != 1 || !recoveryRequestContainsText(requests[0], "exact sole task") {
188+ t.Fatalf("requests = %#v, want one direct request retaining the sole task", requests)
189+ }
190+}
191+
192+func TestPromptWithContextRecoveryFallsBackWhenProactiveSummaryOverflows(t *testing.T) {
193+ client := &proactiveContextClient{summaryErr: errors.New("context window exceeded while summarizing")}
194+ agent := core.NewAgent(client, "model", "system", nil)
195+ history := []provider.Message{
196+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "history"}}},
197+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "answer"}}},
198+ }
199+ agent.SetMessages(history)
200+ agent.SeedLastTurnUsage(provider.Usage{InputTokens: 90})
201+
202+ result, err := PromptWithContextRecovery(context.Background(), agent, "same task", nil, nil, ContextRecoveryOptions{ContextWindow: 100})
203+ if err != nil {
204+ t.Fatalf("PromptWithContextRecovery: %v", err)
205+ }
206+ if result.Compacted {
207+ t.Fatal("overflowing proactive summary reported compaction")
208+ }
209+ client.mu.Lock()
210+ requests := append([]provider.Request(nil), client.requests...)
211+ client.mu.Unlock()
212+ if len(requests) != 2 {
213+ t.Fatalf("provider requests = %d, want one summary and one normal prompt", len(requests))
214+ }
215+ if got := requestUserTextCount(requests[1], "same task"); got != 1 {
216+ t.Fatalf("normal recovery prompt count = %d, want 1", got)
217+ }
218+}
219+
220+func TestPromptWithContextRecoveryStopsBeforeContinuationWhenPersistenceCancels(t *testing.T) {
221+ ctx, cancel := context.WithCancel(context.Background())
222+ client := &proactiveContextClient{}
223+ agent := core.NewAgent(client, "model", "system", nil)
224+ history := []provider.Message{
225+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "history"}}},
226+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "answer"}}},
227+ }
228+ agent.SetMessages(history)
229+ agent.SeedLastTurnUsage(provider.Usage{CacheWriteTokens: 90})
230+ persisted := 0
231+
232+ result, err := PromptWithContextRecovery(ctx, agent, "must not run", nil, nil, ContextRecoveryOptions{
233+ ContextWindow: 100,
234+ PersistCompaction: func([]provider.Message) error {
235+ persisted++
236+ cancel()
237+ return nil
238+ },
239+ })
240+ if !errors.Is(err, context.Canceled) {
241+ t.Fatalf("error = %v, want context cancellation", err)
242+ }
243+ if !result.Compacted || persisted != 1 {
244+ t.Fatalf("compaction result/persistence = %v/%d, want true/1", result.Compacted, persisted)
245+ }
246+ client.mu.Lock()
247+ requestCount := len(client.requests)
248+ client.mu.Unlock()
249+ if requestCount != 1 {
250+ t.Fatalf("provider requests = %d, want summary only", requestCount)
251+ }
252+}
253+
254+func TestPromptWithContextRecoveryRollsBackWhenCanceledAfterSummary(t *testing.T) {
255+ ctx, cancel := context.WithCancel(context.Background())
256+ client := &proactiveContextClient{onSummary: cancel}
257+ agent := core.NewAgent(client, "model", "system", nil)
258+ history := []provider.Message{
259+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "history"}}},
260+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "answer"}}},
261+ }
262+ agent.SetMessages(history)
263+ agent.SeedLastTurnUsage(provider.Usage{CacheWriteTokens: 90})
264+ persisted := 0
265+
266+ _, err := PromptWithContextRecovery(ctx, agent, "must not run", nil, nil, ContextRecoveryOptions{
267+ ContextWindow: 100,
268+ PersistCompaction: func([]provider.Message) error {
269+ persisted++
270+ return nil
271+ },
272+ })
273+ if !errors.Is(err, context.Canceled) {
274+ t.Fatalf("error = %v, want context cancellation", err)
275+ }
276+ if persisted != 0 {
277+ t.Fatalf("persistence calls = %d, want 0", persisted)
278+ }
279+ if got := agent.Messages(); !reflect.DeepEqual(got, history) {
280+ t.Fatalf("transcript after cancellation = %#v, want %#v", got, history)
281+ }
282+ client.mu.Lock()
283+ requestCount := len(client.requests)
284+ client.mu.Unlock()
285+ if requestCount != 1 {
286+ t.Fatalf("provider requests = %d, want summary only", requestCount)
287+ }
288+}
289+
290+func TestPromptWithContextRecoveryRepairsToolPairAtCompactionBoundary(t *testing.T) {
291+ client := &proactiveContextClient{}
292+ agent := core.NewAgent(client, "model", "system", nil)
293+ agent.SetMessages([]provider.Message{
294+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "old"}}},
295+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "split-call", Name: "read"}}},
296+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{CallID: "split-call", Content: []provider.Content{provider.TextBlock{Text: "result"}}}}},
297+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "after tool"}}},
298+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "next"}}},
299+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "ready"}}},
300+ })
301+ agent.SeedLastTurnUsage(provider.Usage{InputTokens: 90})
302+
303+ if _, err := PromptWithContextRecovery(context.Background(), agent, "continue", nil, nil, ContextRecoveryOptions{ContextWindow: 100}); err != nil {
304+ t.Fatalf("PromptWithContextRecovery: %v", err)
305+ }
306+ client.mu.Lock()
307+ requests := append([]provider.Request(nil), client.requests...)
308+ client.mu.Unlock()
309+ if len(requests) != 2 {
310+ t.Fatalf("provider requests = %d, want summary then continuation", len(requests))
311+ }
312+ for _, message := range requests[1].Messages {
313+ for _, content := range message.Content {
314+ if result, ok := content.(provider.ToolResultBlock); ok && result.CallID == "split-call" {
315+ t.Fatalf("continuation contains orphaned tool result: %#v", requests[1].Messages)
316+ }
317+ }
318+ }
319+}
320+
321+func recoveryRequestContainsText(req provider.Request, want string) bool {
322+ for _, message := range req.Messages {
323+ for _, content := range message.Content {
324+ if text, ok := content.(provider.TextBlock); ok && strings.Contains(text.Text, want) {
325+ return true
326+ }
327+ }
328+ }
329+ return false
330+}
331+
25 func (c *hostRecoveryClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {332 func (c *hostRecoveryClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
26 c.mu.Lock()333 c.mu.Lock()
27 c.calls++334 c.calls++
M packages/agent/modes/interactive.go +16 -9
@@ -4483,7 +4483,7 @@ func (i *Interactive) openSettingsDialog() {
44834483 {value: "90", label: "90%", desc: "retain more history before compacting"},
44844484 {value: "95", label: "95%", desc: "retain most history before compacting"},
44854485 }
4486- autoCompactThreshold := normalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
4486+ autoCompactThreshold := NormalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
44874487 autoCompactChoice := 0
44884488 for idx, opt := range autoCompactOptions {
44894489 if opt.value == strconv.Itoa(autoCompactThreshold) {
@@ -4751,7 +4751,7 @@ func (i *Interactive) applyAutoCompactThresholdSetting(value string) {
47514751 if err != nil {
47524752 return
47534753 }
4754- threshold = normalizeAutoCompactThreshold(&threshold)
4754+ threshold = NormalizeAutoCompactThreshold(&threshold)
47554755 if store, ok := i.cfg.SettingsStore.(autoCompactThresholdSettingsStore); ok {
47564756 if err := store.SetAutoCompactThreshold(threshold); err != nil {
47574757 i.mu.Lock()
@@ -7917,9 +7917,11 @@ func autoCompactNoteLine(th tui.Theme, msg string) string {
79177917 return " " + th.FGColor(th.Warning, "⚠ "+msg)
79187918 }
79197919
7920-const defaultAutoCompactThreshold = 85
7921-
7922-func normalizeAutoCompactThreshold(threshold *int) int {
7920+// NormalizeAutoCompactThreshold applies the persisted auto-compaction
7921+// setting's supported values and default. It is exported so non-interactive
7922+// hosts can share the interactive mode's exact configuration semantics.
7923+func NormalizeAutoCompactThreshold(threshold *int) int {
7924+ const defaultAutoCompactThreshold = 85
79237925 if threshold == nil {
79247926 return defaultAutoCompactThreshold
79257927 }
@@ -7931,7 +7933,9 @@ func normalizeAutoCompactThreshold(threshold *int) int {
79317933 }
79327934 }
79337935
7934-func shouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool {
7936+// ShouldAutoCompact reports whether the latest request context reached the
7937+// configured percentage of the model's advertised context window.
7938+func ShouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool {
79357939 if inputTokens <= 0 || contextWindow <= 0 || thresholdPercent <= 0 {
79367940 return false
79377941 }
@@ -8081,8 +8085,8 @@ func (i *Interactive) shouldAutoCompactLocked() bool {
80818085 if err != nil || m.ContextWindow <= 0 {
80828086 return false
80838087 }
8084- threshold := normalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
8085- return shouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold)
8088+ threshold := NormalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
8089+ return ShouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold)
80868090 }
80878091
80888092 func eventAffectsPresentation(ev core.AgentEvent) bool {
@@ -9510,7 +9514,10 @@ func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo in
95109514 i.setSessionTreeError("tree: read selection: " + err.Error())
95119515 return
95129516 }
9513- _ = sess.Close()
9517+ if err := sess.Close(); err != nil {
9518+ i.setSessionTreeError("tree: close selection: " + err.Error())
9519+ return
9520+ }
95149521 msgs = current
95159522 }
95169523 selection, err := sessionTreeSelection(msgs, target)
@@ -4483,7 +4483,7 @@ func (i *Interactive) openSettingsDialog() {
4483 {value: "90", label: "90%", desc: "retain more history before compacting"},4483 {value: "90", label: "90%", desc: "retain more history before compacting"},
4484 {value: "95", label: "95%", desc: "retain most history before compacting"},4484 {value: "95", label: "95%", desc: "retain most history before compacting"},
4485 }4485 }
4486- autoCompactThreshold := normalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)4486+ autoCompactThreshold := NormalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
4487 autoCompactChoice := 04487 autoCompactChoice := 0
4488 for idx, opt := range autoCompactOptions {4488 for idx, opt := range autoCompactOptions {
4489 if opt.value == strconv.Itoa(autoCompactThreshold) {4489 if opt.value == strconv.Itoa(autoCompactThreshold) {
@@ -4751,7 +4751,7 @@ func (i *Interactive) applyAutoCompactThresholdSetting(value string) {
4751 if err != nil {4751 if err != nil {
4752 return4752 return
4753 }4753 }
4754- threshold = normalizeAutoCompactThreshold(&threshold)4754+ threshold = NormalizeAutoCompactThreshold(&threshold)
4755 if store, ok := i.cfg.SettingsStore.(autoCompactThresholdSettingsStore); ok {4755 if store, ok := i.cfg.SettingsStore.(autoCompactThresholdSettingsStore); ok {
4756 if err := store.SetAutoCompactThreshold(threshold); err != nil {4756 if err := store.SetAutoCompactThreshold(threshold); err != nil {
4757 i.mu.Lock()4757 i.mu.Lock()
@@ -7917,9 +7917,11 @@ func autoCompactNoteLine(th tui.Theme, msg string) string {
7917 return " " + th.FGColor(th.Warning, "⚠ "+msg)7917 return " " + th.FGColor(th.Warning, "⚠ "+msg)
7918 }7918 }
7919 7919
7920-const defaultAutoCompactThreshold = 857920+// NormalizeAutoCompactThreshold applies the persisted auto-compaction
7921-7921+// setting's supported values and default. It is exported so non-interactive
7922-func normalizeAutoCompactThreshold(threshold *int) int {7922+// hosts can share the interactive mode's exact configuration semantics.
7923+func NormalizeAutoCompactThreshold(threshold *int) int {
7924+ const defaultAutoCompactThreshold = 85
7923 if threshold == nil {7925 if threshold == nil {
7924 return defaultAutoCompactThreshold7926 return defaultAutoCompactThreshold
7925 }7927 }
@@ -7931,7 +7933,9 @@ func normalizeAutoCompactThreshold(threshold *int) int {
7931 }7933 }
7932 }7934 }
7933 7935
7934-func shouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool {7936+// ShouldAutoCompact reports whether the latest request context reached the
7937+// configured percentage of the model's advertised context window.
7938+func ShouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool {
7935 if inputTokens <= 0 || contextWindow <= 0 || thresholdPercent <= 0 {7939 if inputTokens <= 0 || contextWindow <= 0 || thresholdPercent <= 0 {
7936 return false7940 return false
7937 }7941 }
@@ -8081,8 +8085,8 @@ func (i *Interactive) shouldAutoCompactLocked() bool {
8081 if err != nil || m.ContextWindow <= 0 {8085 if err != nil || m.ContextWindow <= 0 {
8082 return false8086 return false
8083 }8087 }
8084- threshold := normalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)8088+ threshold := NormalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
8085- return shouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold)8089+ return ShouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold)
8086 }8090 }
8087 8091
8088 func eventAffectsPresentation(ev core.AgentEvent) bool {8092 func eventAffectsPresentation(ev core.AgentEvent) bool {
@@ -9510,7 +9514,10 @@ func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo in
9510 i.setSessionTreeError("tree: read selection: " + err.Error())9514 i.setSessionTreeError("tree: read selection: " + err.Error())
9511 return9515 return
9512 }9516 }
9513- _ = sess.Close()9517+ if err := sess.Close(); err != nil {
9518+ i.setSessionTreeError("tree: close selection: " + err.Error())
9519+ return
9520+ }
9514 msgs = current9521 msgs = current
9515 }9522 }
9516 selection, err := sessionTreeSelection(msgs, target)9523 selection, err := sessionTreeSelection(msgs, target)
M packages/agent/subagent_worker.go +93 -15
@@ -86,7 +86,7 @@ func (b *workerTurnBudget) start(maxTurns int, newRun bool) (step, lifetime, cur
8686 return b.sequence, b.lifetime, b.current, true
8787 }
8888
89-func runSubagentWorkerMode(ctx context.Context, args Args, version string) error {
89+func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runErr error) {
9090 if args.SubagentWorker == "" {
9191 return fmt.Errorf("--subagent-worker requires a socket path")
9292 }
@@ -104,6 +104,10 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) error
104104 args.inheritedAccountID = inherited.AccountID
105105 }
106106
107+ cfg, err := loadSubagentWorkerConfig()
108+ if err != nil {
109+ return err
110+ }
107111 r, err := Resolve(args, true)
108112 if err != nil {
109113 return err
@@ -145,10 +149,20 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) error
145149 sessionPersistence.record(err)
146150 }
147151 }
148- defer sess.Close()
152+ ag.OnUsage = func(usage provider.Usage) {
153+ if err := sess.AppendUsage(usage, usage); err != nil {
154+ sessionPersistence.record(err)
155+ }
156+ }
157+ defer func() { runErr = joinSessionCloseError(runErr, sess) }()
149158 }
150159 announceSession(extMgr, sess)
151160
161+ // Resolve retains metadata for catalog models and for valid synthesized
162+ // local/routed/open-catalog models. The active agent also survives a session
163+ // resume that rebuilt it for a different provider/model pair.
164+ contextWindow := ag.ContextWindow
165+
152166 // Open the inbox listener BEFORE emitting agent_ready so the
153167 // supervisor can dial through on the very first send. The
154168 // subagent supervisor's Inbox retries dialing for a
@@ -289,19 +303,41 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) error
289303 em.emit(ev.Type(), data)
290304 }
291305
306+ baseline := workerTranscriptState{
307+ messages: ag.Messages(),
308+ cost: ag.Cost(),
309+ lastTurn: ag.LastTurnUsage(),
310+ }
292311 var persistCompaction func([]provider.Message) error
293312 if sess != nil {
294- persistCompaction = sess.AppendCompaction
313+ // Buffer the checkpoint until the continued turn completes. The final
314+ // transcript and cumulative usage are then persisted in one JSONL row.
315+ persistCompaction = func([]provider.Message) error {
316+ return c.Err()
317+ }
295318 }
296- start, err := promptWithContextRecovery(c, ag, prompt, sink, persistCompaction)
297- if sess != nil && ag.OnMessageAppended == nil {
298- WriteNewTranscript(ag, sess, start)
319+ recovery, err := promptWithContextRecovery(c, ag, prompt, sink, modes.ContextRecoveryOptions{
320+ PersistCompaction: persistCompaction,
321+ ContextWindow: contextWindow,
322+ AutoCompactThreshold: cfg.AutoCompactThreshold,
323+ })
324+ output := finalAssistantText(ag.Messages()[recovery.OutputStart:])
325+ // Message and usage callbacks persist ordinary turns incrementally. A
326+ // compacted turn still needs one atomic replacement checkpoint so the
327+ // durable transcript cannot mix generations.
328+ if recovery.Compacted || ag.OnMessageAppended == nil {
329+ if persistErr := persistWorkerTranscript(c, ag, sess, recovery.OutputStart, recovery.Compacted, baseline); persistErr != nil {
330+ if err != nil {
331+ err = errors.Join(err, persistErr)
332+ } else {
333+ err = persistErr
334+ }
335+ }
299336 }
300337 if sessionErr := sessionPersistence.err(); err == nil && sessionErr != nil {
301338 err = sessionErr
302339 }
303340
304- output := finalAssistantText(ag.Messages()[start:])
305341 resultStatus := "succeeded"
306342 if err != nil {
307343 if errors.Is(err, context.Canceled) {
@@ -541,15 +577,57 @@ func canonicalWorkerEvent(typ string) string {
541577
542578 const subagentContextLimitMessage = "request exceeds the model context window; narrow the task or reduce gathered context"
543579
580+func loadSubagentWorkerConfig() (Config, error) {
581+ cfg, err := LoadConfig()
582+ if err != nil {
583+ return Config{}, fmt.Errorf("load subagent worker config: %w", err)
584+ }
585+ return cfg, nil
586+}
587+
588+// workerTranscriptState is restored when an atomic post-compaction checkpoint
589+// cannot be persisted.
590+type workerTranscriptState struct {
591+ messages []provider.Message
592+ cost provider.Usage
593+ lastTurn provider.Usage
594+}
595+
596+func persistWorkerTranscript(ctx context.Context, ag *core.Agent, sess *core.Session, from int, compacted bool, baseline workerTranscriptState) error {
597+ if sess == nil || ag == nil {
598+ return nil
599+ }
600+ if !compacted {
601+ if err := ctx.Err(); err != nil {
602+ return err
603+ }
604+ if err := WriteNewTranscript(ag, sess, from); err != nil {
605+ return fmt.Errorf("persist subagent transcript: %w", err)
606+ }
607+ return nil
608+ }
609+
610+ rollback := func() {
611+ ag.SetMessages(baseline.messages)
612+ ag.SeedCost(baseline.cost)
613+ ag.SeedLastTurnUsage(baseline.lastTurn)
614+ }
615+ if err := ctx.Err(); err != nil {
616+ rollback()
617+ return err
618+ }
619+ if err := sess.AppendCompactionWithUsage(ag.Messages(), ag.Cost()); err != nil {
620+ rollback()
621+ return fmt.Errorf("persist compacted subagent transcript: %w", err)
622+ }
623+ return nil
624+}
625+
544626 // promptWithContextRecovery preserves the worker's existing helper boundary
545-// while delegating its one-shot overflow recovery to modes. The returned index
546-// is the first message to include in the result or append after a persisted
547-// compaction checkpoint.
548-func promptWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, sink func(core.AgentEvent), persistCompaction func([]provider.Message) error) (outputStart int, err error) {
549- result, err := modes.PromptWithContextRecovery(ctx, ag, prompt, nil, sink, modes.ContextRecoveryOptions{
550- PersistCompaction: persistCompaction,
551- })
552- return result.OutputStart, err
627+// while delegating proactive compaction and one-shot overflow recovery to
628+// modes. The result includes both the output index and compaction state.
629+func promptWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, sink func(core.AgentEvent), options modes.ContextRecoveryOptions) (modes.ContextRecoveryResult, error) {
630+ return modes.PromptWithContextRecovery(ctx, ag, prompt, nil, sink, options)
553631 }
554632
555633 func finalAssistantText(messages []provider.Message) string {
@@ -86,7 +86,7 @@ func (b *workerTurnBudget) start(maxTurns int, newRun bool) (step, lifetime, cur
86 return b.sequence, b.lifetime, b.current, true86 return b.sequence, b.lifetime, b.current, true
87 }87 }
88 88
89-func runSubagentWorkerMode(ctx context.Context, args Args, version string) error {89+func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runErr error) {
90 if args.SubagentWorker == "" {90 if args.SubagentWorker == "" {
91 return fmt.Errorf("--subagent-worker requires a socket path")91 return fmt.Errorf("--subagent-worker requires a socket path")
92 }92 }
@@ -104,6 +104,10 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) error
104 args.inheritedAccountID = inherited.AccountID104 args.inheritedAccountID = inherited.AccountID
105 }105 }
106 106
107+ cfg, err := loadSubagentWorkerConfig()
108+ if err != nil {
109+ return err
110+ }
107 r, err := Resolve(args, true)111 r, err := Resolve(args, true)
108 if err != nil {112 if err != nil {
109 return err113 return err
@@ -145,10 +149,20 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) error
145 sessionPersistence.record(err)149 sessionPersistence.record(err)
146 }150 }
147 }151 }
148- defer sess.Close()152+ ag.OnUsage = func(usage provider.Usage) {
153+ if err := sess.AppendUsage(usage, usage); err != nil {
154+ sessionPersistence.record(err)
155+ }
156+ }
157+ defer func() { runErr = joinSessionCloseError(runErr, sess) }()
149 }158 }
150 announceSession(extMgr, sess)159 announceSession(extMgr, sess)
151 160
161+ // Resolve retains metadata for catalog models and for valid synthesized
162+ // local/routed/open-catalog models. The active agent also survives a session
163+ // resume that rebuilt it for a different provider/model pair.
164+ contextWindow := ag.ContextWindow
165+
152 // Open the inbox listener BEFORE emitting agent_ready so the166 // Open the inbox listener BEFORE emitting agent_ready so the
153 // supervisor can dial through on the very first send. The167 // supervisor can dial through on the very first send. The
154 // subagent supervisor's Inbox retries dialing for a168 // subagent supervisor's Inbox retries dialing for a
@@ -289,19 +303,41 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) error
289 em.emit(ev.Type(), data)303 em.emit(ev.Type(), data)
290 }304 }
291 305
306+ baseline := workerTranscriptState{
307+ messages: ag.Messages(),
308+ cost: ag.Cost(),
309+ lastTurn: ag.LastTurnUsage(),
310+ }
292 var persistCompaction func([]provider.Message) error311 var persistCompaction func([]provider.Message) error
293 if sess != nil {312 if sess != nil {
294- persistCompaction = sess.AppendCompaction313+ // Buffer the checkpoint until the continued turn completes. The final
314+ // transcript and cumulative usage are then persisted in one JSONL row.
315+ persistCompaction = func([]provider.Message) error {
316+ return c.Err()
317+ }
295 }318 }
296- start, err := promptWithContextRecovery(c, ag, prompt, sink, persistCompaction)319+ recovery, err := promptWithContextRecovery(c, ag, prompt, sink, modes.ContextRecoveryOptions{
297- if sess != nil && ag.OnMessageAppended == nil {320+ PersistCompaction: persistCompaction,
298- WriteNewTranscript(ag, sess, start)321+ ContextWindow: contextWindow,
322+ AutoCompactThreshold: cfg.AutoCompactThreshold,
323+ })
324+ output := finalAssistantText(ag.Messages()[recovery.OutputStart:])
325+ // Message and usage callbacks persist ordinary turns incrementally. A
326+ // compacted turn still needs one atomic replacement checkpoint so the
327+ // durable transcript cannot mix generations.
328+ if recovery.Compacted || ag.OnMessageAppended == nil {
329+ if persistErr := persistWorkerTranscript(c, ag, sess, recovery.OutputStart, recovery.Compacted, baseline); persistErr != nil {
330+ if err != nil {
331+ err = errors.Join(err, persistErr)
332+ } else {
333+ err = persistErr
334+ }
335+ }
299 }336 }
300 if sessionErr := sessionPersistence.err(); err == nil && sessionErr != nil {337 if sessionErr := sessionPersistence.err(); err == nil && sessionErr != nil {
301 err = sessionErr338 err = sessionErr
302 }339 }
303 340
304- output := finalAssistantText(ag.Messages()[start:])
305 resultStatus := "succeeded"341 resultStatus := "succeeded"
306 if err != nil {342 if err != nil {
307 if errors.Is(err, context.Canceled) {343 if errors.Is(err, context.Canceled) {
@@ -541,15 +577,57 @@ func canonicalWorkerEvent(typ string) string {
541 577
542 const subagentContextLimitMessage = "request exceeds the model context window; narrow the task or reduce gathered context"578 const subagentContextLimitMessage = "request exceeds the model context window; narrow the task or reduce gathered context"
543 579
580+func loadSubagentWorkerConfig() (Config, error) {
581+ cfg, err := LoadConfig()
582+ if err != nil {
583+ return Config{}, fmt.Errorf("load subagent worker config: %w", err)
584+ }
585+ return cfg, nil
586+}
587+
588+// workerTranscriptState is restored when an atomic post-compaction checkpoint
589+// cannot be persisted.
590+type workerTranscriptState struct {
591+ messages []provider.Message
592+ cost provider.Usage
593+ lastTurn provider.Usage
594+}
595+
596+func persistWorkerTranscript(ctx context.Context, ag *core.Agent, sess *core.Session, from int, compacted bool, baseline workerTranscriptState) error {
597+ if sess == nil || ag == nil {
598+ return nil
599+ }
600+ if !compacted {
601+ if err := ctx.Err(); err != nil {
602+ return err
603+ }
604+ if err := WriteNewTranscript(ag, sess, from); err != nil {
605+ return fmt.Errorf("persist subagent transcript: %w", err)
606+ }
607+ return nil
608+ }
609+
610+ rollback := func() {
611+ ag.SetMessages(baseline.messages)
612+ ag.SeedCost(baseline.cost)
613+ ag.SeedLastTurnUsage(baseline.lastTurn)
614+ }
615+ if err := ctx.Err(); err != nil {
616+ rollback()
617+ return err
618+ }
619+ if err := sess.AppendCompactionWithUsage(ag.Messages(), ag.Cost()); err != nil {
620+ rollback()
621+ return fmt.Errorf("persist compacted subagent transcript: %w", err)
622+ }
623+ return nil
624+}
625+
544 // promptWithContextRecovery preserves the worker's existing helper boundary626 // promptWithContextRecovery preserves the worker's existing helper boundary
545-// while delegating its one-shot overflow recovery to modes. The returned index627+// while delegating proactive compaction and one-shot overflow recovery to
546-// is the first message to include in the result or append after a persisted628+// modes. The result includes both the output index and compaction state.
547-// compaction checkpoint.629+func promptWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, sink func(core.AgentEvent), options modes.ContextRecoveryOptions) (modes.ContextRecoveryResult, error) {
548-func promptWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, sink func(core.AgentEvent), persistCompaction func([]provider.Message) error) (outputStart int, err error) {630+ return modes.PromptWithContextRecovery(ctx, ag, prompt, nil, sink, options)
549- result, err := modes.PromptWithContextRecovery(ctx, ag, prompt, nil, sink, modes.ContextRecoveryOptions{
550- PersistCompaction: persistCompaction,
551- })
552- return result.OutputStart, err
553 }631 }
554 632
555 func finalAssistantText(messages []provider.Message) string {633 func finalAssistantText(messages []provider.Message) string {
M packages/agent/subagent_worker_test.go +342 -11
@@ -8,10 +8,12 @@ import (
88 "io"
99 "os"
1010 "path/filepath"
11+ "reflect"
1112 "strings"
1213 "sync"
1314 "testing"
1415
16+ "github.com/bnema/zut/packages/agent/modes"
1517 "github.com/bnema/zut/packages/agent/subagents"
1618 "github.com/bnema/zut/packages/core"
1719 "github.com/bnema/zut/packages/provider"
@@ -81,6 +83,33 @@ func (c *subagentContextRecoveryClient) Stream(_ context.Context, req provider.R
8183 return events, nil
8284 }
8385
86+type subagentProactiveClient struct {
87+ mu sync.Mutex
88+ requests []provider.Request
89+}
90+
91+func (c *subagentProactiveClient) Name() string { return "subagent-proactive-test" }
92+
93+func (c *subagentProactiveClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
94+ c.mu.Lock()
95+ c.requests = append(c.requests, req)
96+ c.mu.Unlock()
97+ events := make(chan provider.Event, 2)
98+ go func() {
99+ defer close(events)
100+ if strings.Contains(req.System, "context summarization assistant") {
101+ events <- provider.EventTextDelta{Delta: "worker summary"}
102+ events <- provider.EventDone{Stop: provider.StopEnd}
103+ return
104+ }
105+ events <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
106+ Role: provider.RoleAssistant,
107+ Content: []provider.Content{provider.TextBlock{Text: "continued same task"}},
108+ }}
109+ }()
110+ return events, nil
111+}
112+
84113 func TestPromptWithContextRecoveryCompactsAndContinues(t *testing.T) {
85114 client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)}
86115 agent := core.NewAgent(client, "test-model", "", nil)
@@ -91,11 +120,14 @@ func TestPromptWithContextRecoveryCompactsAndContinues(t *testing.T) {
91120 {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
92121 })
93122
94- outputStart, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil)
123+ recovery, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, modes.ContextRecoveryOptions{})
95124 if err != nil {
96125 t.Fatalf("promptWithContextRecovery returned %v", err)
97126 }
98- if got := finalAssistantText(agent.Messages()[outputStart:]); got != "recovered" {
127+ if !recovery.Compacted {
128+ t.Fatal("promptWithContextRecovery did not report compaction")
129+ }
130+ if got := finalAssistantText(agent.Messages()[recovery.OutputStart:]); got != "recovered" {
99131 t.Fatalf("final assistant text = %q, want recovered", got)
100132 }
101133
@@ -123,7 +155,7 @@ func TestPromptWithContextRecoveryStopsAfterOneRetry(t *testing.T) {
123155 agent := core.NewAgent(client, "test-model", "", nil)
124156 agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}})
125157
126- _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil)
158+ _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, modes.ContextRecoveryOptions{})
127159 if !provider.IsContextOverflowError(err) {
128160 t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err)
129161 }
@@ -147,7 +179,7 @@ func TestPromptWithContextRecoveryDoesNotCompactAfterCancellation(t *testing.T)
147179 if turnEnd, ok := event.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) {
148180 cancel()
149181 }
150- }, nil)
182+ }, modes.ContextRecoveryOptions{})
151183 if !provider.IsContextOverflowError(err) {
152184 t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err)
153185 }
@@ -170,7 +202,7 @@ func TestPromptWithContextRecoveryPropagatesCompactionFailure(t *testing.T) {
170202 agent := core.NewAgent(client, "test-model", "", nil)
171203 agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}})
172204
173- _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil)
205+ _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, modes.ContextRecoveryOptions{})
174206 if err == nil || !strings.Contains(err.Error(), "compact transcript after initial overflow") {
175207 t.Fatalf("promptWithContextRecovery error = %v, want compaction failure", err)
176208 }
@@ -184,7 +216,7 @@ func TestPromptWithContextRecoveryKeepsSinglePromptIntact(t *testing.T) {
184216 client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)}
185217 agent := core.NewAgent(client, "test-model", "", nil)
186218
187- _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil)
219+ _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, modes.ContextRecoveryOptions{})
188220 if !provider.IsContextOverflowError(err) {
189221 t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err)
190222 }
@@ -216,8 +248,8 @@ func TestPromptWithContextRecoveryRetainsPromptWhenPersistenceFails(t *testing.T
216248 agent := core.NewAgent(client, "test-model", "", nil)
217249 agent.SetMessages(history)
218250
219- outputStart, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, func([]provider.Message) error {
220- return errors.New("persist failed")
251+ recovery, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, modes.ContextRecoveryOptions{
252+ PersistCompaction: func([]provider.Message) error { return errors.New("persist failed") },
221253 })
222254 if err == nil || !strings.Contains(err.Error(), "persist compacted transcript") {
223255 t.Fatalf("promptWithContextRecovery error = %v, want persistence failure", err)
@@ -225,7 +257,9 @@ func TestPromptWithContextRecoveryRetainsPromptWhenPersistenceFails(t *testing.T
225257 if got := workerRequestMessageTextCount(agent.Messages(), "prior context"); got != 1 || workerRequestMessageTextCount(agent.Messages(), "finish the task") != 1 {
226258 t.Fatalf("agent transcript lost the pending task after persistence failure: %#v", agent.Messages())
227259 }
228- WriteNewTranscript(agent, session, outputStart)
260+ if err := WriteNewTranscript(agent, session, recovery.OutputStart); err != nil {
261+ t.Fatalf("persist transcript: %v", err)
262+ }
229263 if err := session.Close(); err != nil {
230264 t.Fatalf("close session: %v", err)
231265 }
@@ -269,11 +303,15 @@ func TestPromptWithContextRecoveryPersistsCompaction(t *testing.T) {
269303
270304 agent := core.NewAgent(client, "test-model", "", nil)
271305 agent.SetMessages(history)
272- outputStart, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, session.AppendCompaction)
306+ recovery, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, modes.ContextRecoveryOptions{
307+ PersistCompaction: session.AppendCompaction,
308+ })
273309 if err != nil {
274310 t.Fatalf("promptWithContextRecovery returned %v", err)
275311 }
276- WriteNewTranscript(agent, session, outputStart)
312+ if err := WriteNewTranscript(agent, session, recovery.OutputStart); err != nil {
313+ t.Fatalf("persist transcript: %v", err)
314+ }
277315 if err := session.Close(); err != nil {
278316 t.Fatalf("close session: %v", err)
279317 }
@@ -315,6 +353,299 @@ func TestPromptWithContextRecoveryPersistsCompaction(t *testing.T) {
315353 }
316354 }
317355
356+func TestWorkerUsesConfiguredAutoCompactionThreshold(t *testing.T) {
357+ t.Setenv("ZUT_HOME", t.TempDir())
358+ threshold := 70
359+ if err := SaveConfig(Config{AutoCompactThreshold: &threshold}); err != nil {
360+ t.Fatalf("save config: %v", err)
361+ }
362+ cfg, err := loadSubagentWorkerConfig()
363+ if err != nil {
364+ t.Fatalf("load worker config: %v", err)
365+ }
366+
367+ client := &subagentProactiveClient{}
368+ agent := core.NewAgent(client, "model", "", nil)
369+ agent.SetMessages([]provider.Message{
370+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "history"}}},
371+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "answer"}}},
372+ })
373+ agent.SeedLastTurnUsage(provider.Usage{CacheReadTokens: 75})
374+ recovery, err := promptWithContextRecovery(context.Background(), agent, "finish exact task", nil, modes.ContextRecoveryOptions{
375+ ContextWindow: 100,
376+ AutoCompactThreshold: cfg.AutoCompactThreshold,
377+ })
378+ if err != nil {
379+ t.Fatalf("promptWithContextRecovery: %v", err)
380+ }
381+ if got := finalAssistantText(agent.Messages()[recovery.OutputStart:]); got != "continued same task" {
382+ t.Fatalf("continued output = %q", got)
383+ }
384+ client.mu.Lock()
385+ requests := append([]provider.Request(nil), client.requests...)
386+ client.mu.Unlock()
387+ if len(requests) != 2 || workerRequestUserTextCount(requests[1], "finish exact task") != 1 {
388+ t.Fatalf("requests = %#v, want compaction then the same task once", requests)
389+ }
390+}
391+
392+func TestWorkerConfigLoadRejectsMalformedConfig(t *testing.T) {
393+ t.Setenv("ZUT_HOME", t.TempDir())
394+ if err := os.MkdirAll(filepath.Dir(ConfigPath()), 0o755); err != nil {
395+ t.Fatal(err)
396+ }
397+ if err := os.WriteFile(ConfigPath(), []byte("{malformed"), 0o600); err != nil {
398+ t.Fatal(err)
399+ }
400+ if _, err := loadSubagentWorkerConfig(); err == nil || !strings.Contains(err.Error(), "load subagent worker config") {
401+ t.Fatalf("loadSubagentWorkerConfig error = %v, want parse failure", err)
402+ }
403+}
404+
405+type subagentSummaryUsageClient struct{}
406+
407+func (c *subagentSummaryUsageClient) Name() string { return "subagent-summary-usage-test" }
408+
409+func (c *subagentSummaryUsageClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
410+ events := make(chan provider.Event, 3)
411+ go func() {
412+ defer close(events)
413+ if strings.Contains(req.System, "context summarization assistant") {
414+ events <- provider.EventUsage{Usage: provider.Usage{InputTokens: 3, OutputTokens: 1, CostUSD: 0.03}}
415+ events <- provider.EventTextDelta{Delta: "worker summary"}
416+ events <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
417+ Role: provider.RoleAssistant,
418+ Content: []provider.Content{provider.TextBlock{Text: "worker summary"}},
419+ }}
420+ return
421+ }
422+ events <- provider.EventUsage{Usage: provider.Usage{InputTokens: 5, OutputTokens: 2, CostUSD: 0.05}}
423+ events <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
424+ Role: provider.RoleAssistant,
425+ Content: []provider.Content{provider.TextBlock{Text: "continued"}},
426+ }}
427+ }()
428+ return events, nil
429+}
430+
431+func TestPersistWorkerSummaryUsageReopensWithCumulativeUsage(t *testing.T) {
432+ session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
433+ if err != nil {
434+ t.Fatal(err)
435+ }
436+ history := []provider.Message{
437+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "old task"}}},
438+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "old answer"}}},
439+ }
440+ for _, message := range history {
441+ if err := session.AppendMessage(message); err != nil {
442+ t.Fatal(err)
443+ }
444+ }
445+ baseline := provider.Usage{InputTokens: 10, OutputTokens: 2, CostUSD: 0.1}
446+ if err := session.AppendUsage(baseline, baseline); err != nil {
447+ t.Fatal(err)
448+ }
449+ agent := core.NewAgent(&subagentSummaryUsageClient{}, "model", "", nil)
450+ agent.SetMessages(history)
451+ agent.SeedCost(baseline)
452+ agent.SeedLastTurnUsage(provider.Usage{CacheReadTokens: 90})
453+ recovery, err := promptWithContextRecovery(context.Background(), agent, "continue task", nil, modes.ContextRecoveryOptions{
454+ ContextWindow: 100,
455+ })
456+ if err != nil {
457+ t.Fatalf("promptWithContextRecovery: %v", err)
458+ }
459+ if recovery.OutputStart != len(history) || !recovery.Compacted {
460+ t.Fatalf("compaction recovery = %+v, want output start %d and compacted", recovery, len(history))
461+ }
462+ expected := provider.Usage{InputTokens: 18, OutputTokens: 5, CostUSD: 0.18}
463+ if !reflect.DeepEqual(agent.Cost(), expected) {
464+ t.Fatalf("worker cumulative usage = %#v, want %#v", agent.Cost(), expected)
465+ }
466+ if err := persistWorkerTranscript(context.Background(), agent, session, recovery.OutputStart, recovery.Compacted, workerTranscriptState{messages: history, cost: baseline}); err != nil {
467+ t.Fatalf("persistWorkerTranscript: %v", err)
468+ }
469+ if err := session.Close(); err != nil {
470+ t.Fatal(err)
471+ }
472+ cumulative, lastTurn, err := core.SessionUsageDetail(session.Path)
473+ if err != nil {
474+ t.Fatal(err)
475+ }
476+ if !reflect.DeepEqual(cumulative, expected) {
477+ t.Fatalf("reopened cumulative usage = %#v, want %#v", cumulative, expected)
478+ }
479+ wantLast := provider.Usage{InputTokens: 8, OutputTokens: 3, CostUSD: expected.CostUSD - baseline.CostUSD}
480+ if !reflect.DeepEqual(lastTurn, wantLast) {
481+ t.Fatalf("reopened last-turn usage = %#v, want %#v", lastTurn, wantLast)
482+ }
483+}
484+
485+func TestPersistWorkerCompactedTranscriptReopensWithTurnAndUsage(t *testing.T) {
486+ session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
487+ if err != nil {
488+ t.Fatal(err)
489+ }
490+ baselineMessage := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "baseline"}}}
491+ if err := session.AppendMessage(baselineMessage); err != nil {
492+ t.Fatal(err)
493+ }
494+ baselineUsage := provider.Usage{InputTokens: 10, OutputTokens: 2}
495+ if err := session.AppendUsage(baselineUsage, baselineUsage); err != nil {
496+ t.Fatal(err)
497+ }
498+
499+ agent := core.NewAgent(&subagentProactiveClient{}, "model", "", nil)
500+ agent.SetMessages([]provider.Message{
501+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "summary"}}},
502+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "current task"}}},
503+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "current answer"}}},
504+ })
505+ cumulative := provider.Usage{InputTokens: 30, OutputTokens: 8, CacheReadTokens: 4}
506+ agent.SeedCost(cumulative)
507+ if err := persistWorkerTranscript(context.Background(), agent, session, 1, true, workerTranscriptState{
508+ messages: []provider.Message{baselineMessage},
509+ cost: baselineUsage,
510+ }); err != nil {
511+ t.Fatalf("persistWorkerTranscript: %v", err)
512+ }
513+ if err := session.Close(); err != nil {
514+ t.Fatal(err)
515+ }
516+
517+ snapshot, err := core.ReadSessionSnapshot(session.Path)
518+ if err != nil {
519+ t.Fatal(err)
520+ }
521+ if workerRequestMessageTextCount(snapshot.Messages, "current task") != 1 || finalAssistantText(snapshot.Messages) != "current answer" {
522+ t.Fatalf("reopened messages = %#v, want full continued turn", snapshot.Messages)
523+ }
524+ if len(snapshot.UsageCheckpoints) == 0 || !reflect.DeepEqual(snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-1].Cumulative, cumulative) {
525+ t.Fatalf("usage checkpoints = %#v, want cumulative %#v", snapshot.UsageCheckpoints, cumulative)
526+ }
527+}
528+
529+func TestPersistWorkerCompactionFailureRollsBackAndKeepsSessionReopenable(t *testing.T) {
530+ session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
531+ if err != nil {
532+ t.Fatal(err)
533+ }
534+ baselineMessage := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "baseline"}}}
535+ if err := session.AppendMessage(baselineMessage); err != nil {
536+ t.Fatal(err)
537+ }
538+ baselineUsage := provider.Usage{InputTokens: 10, OutputTokens: 2}
539+ if err := session.AppendUsage(baselineUsage, baselineUsage); err != nil {
540+ t.Fatal(err)
541+ }
542+ if err := session.Close(); err != nil {
543+ t.Fatal(err)
544+ }
545+
546+ agent := core.NewAgent(&subagentProactiveClient{}, "model", "", nil)
547+ agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "unsaved compacted turn"}}}})
548+ agent.SeedCost(provider.Usage{InputTokens: 99})
549+ baseline := workerTranscriptState{messages: []provider.Message{baselineMessage}, cost: baselineUsage, lastTurn: baselineUsage}
550+ if err := persistWorkerTranscript(context.Background(), agent, session, 0, true, baseline); err == nil {
551+ t.Fatal("persistWorkerTranscript succeeded on closed session")
552+ }
553+ if !reflect.DeepEqual(agent.Messages(), baseline.messages) || !reflect.DeepEqual(agent.Cost(), baselineUsage) {
554+ t.Fatalf("agent was not rolled back: messages=%#v cost=%#v", agent.Messages(), agent.Cost())
555+ }
556+ snapshot, err := core.ReadSessionSnapshot(session.Path)
557+ if err != nil {
558+ t.Fatalf("reopen snapshot: %v", err)
559+ }
560+ if !reflect.DeepEqual(snapshot.Messages, baseline.messages) {
561+ t.Fatalf("reopened transcript = %#v, want baseline %#v", snapshot.Messages, baseline.messages)
562+ }
563+ if len(snapshot.UsageCheckpoints) != 1 || !reflect.DeepEqual(snapshot.UsageCheckpoints[0].Cumulative, baselineUsage) {
564+ t.Fatalf("reopened usage = %#v, want baseline", snapshot.UsageCheckpoints)
565+ }
566+}
567+
568+func TestPersistWorkerTranscriptSkipsCanceledNonCompactedTurn(t *testing.T) {
569+ session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
570+ if err != nil {
571+ t.Fatal(err)
572+ }
573+ baselineMessage := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "baseline"}}}
574+ if err := session.AppendMessage(baselineMessage); err != nil {
575+ t.Fatal(err)
576+ }
577+ agent := core.NewAgent(&subagentProactiveClient{}, "model", "", nil)
578+ agent.SetMessages([]provider.Message{
579+ baselineMessage,
580+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "canceled answer"}}},
581+ })
582+ ctx, cancel := context.WithCancel(context.Background())
583+ cancel()
584+ if err := persistWorkerTranscript(ctx, agent, session, 1, false, workerTranscriptState{messages: []provider.Message{baselineMessage}}); !errors.Is(err, context.Canceled) {
585+ t.Fatalf("persistWorkerTranscript error = %v, want cancellation", err)
586+ }
587+ if err := session.Close(); err != nil {
588+ t.Fatal(err)
589+ }
590+ snapshot, err := core.ReadSessionSnapshot(session.Path)
591+ if err != nil {
592+ t.Fatal(err)
593+ }
594+ if !reflect.DeepEqual(snapshot.Messages, []provider.Message{baselineMessage}) {
595+ t.Fatalf("reopened messages = %#v, want baseline only", snapshot.Messages)
596+ }
597+}
598+
599+func TestPersistWorkerCompactionHonorsCancellation(t *testing.T) {
600+ session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
601+ if err != nil {
602+ t.Fatal(err)
603+ }
604+ baselineMessage := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "baseline"}}}
605+ if err := session.AppendMessage(baselineMessage); err != nil {
606+ t.Fatal(err)
607+ }
608+ baseline := workerTranscriptState{messages: []provider.Message{baselineMessage}}
609+ agent := core.NewAgent(&subagentProactiveClient{}, "model", "", nil)
610+ agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "must not persist"}}}})
611+ ctx, cancel := context.WithCancel(context.Background())
612+ cancel()
613+ if err := persistWorkerTranscript(ctx, agent, session, 0, true, baseline); !errors.Is(err, context.Canceled) {
614+ t.Fatalf("persistWorkerTranscript error = %v, want cancellation", err)
615+ }
616+ if !reflect.DeepEqual(agent.Messages(), baseline.messages) {
617+ t.Fatalf("agent messages = %#v, want rollback %#v", agent.Messages(), baseline.messages)
618+ }
619+ if err := session.Close(); err != nil {
620+ t.Fatal(err)
621+ }
622+ snapshot, err := core.ReadSessionSnapshot(session.Path)
623+ if err != nil {
624+ t.Fatal(err)
625+ }
626+ if !reflect.DeepEqual(snapshot.Messages, baseline.messages) {
627+ t.Fatalf("reopened messages = %#v, want baseline", snapshot.Messages)
628+ }
629+}
630+
631+func TestWriteNewTranscriptReturnsPersistenceErrors(t *testing.T) {
632+ session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
633+ if err != nil {
634+ t.Fatal(err)
635+ }
636+ if err := session.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "baseline"}}}); err != nil {
637+ t.Fatal(err)
638+ }
639+ if err := session.Close(); err != nil {
640+ t.Fatal(err)
641+ }
642+ agent := core.NewAgent(&subagentProactiveClient{}, "model", "", nil)
643+ agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "new"}}}})
644+ if err := WriteNewTranscript(agent, session, 0); err == nil || !strings.Contains(err.Error(), "append transcript message") {
645+ t.Fatalf("WriteNewTranscript error = %v, want append failure", err)
646+ }
647+}
648+
318649 func workerRequestMessageTextCount(messages []provider.Message, want string) int {
319650 count := 0
320651 for _, message := range messages {
@@ -8,10 +8,12 @@ import (
8 "io"8 "io"
9 "os"9 "os"
10 "path/filepath"10 "path/filepath"
11+ "reflect"
11 "strings"12 "strings"
12 "sync"13 "sync"
13 "testing"14 "testing"
14 15
16+ "github.com/bnema/zut/packages/agent/modes"
15 "github.com/bnema/zut/packages/agent/subagents"17 "github.com/bnema/zut/packages/agent/subagents"
16 "github.com/bnema/zut/packages/core"18 "github.com/bnema/zut/packages/core"
17 "github.com/bnema/zut/packages/provider"19 "github.com/bnema/zut/packages/provider"
@@ -81,6 +83,33 @@ func (c *subagentContextRecoveryClient) Stream(_ context.Context, req provider.R
81 return events, nil83 return events, nil
82 }84 }
83 85
86+type subagentProactiveClient struct {
87+ mu sync.Mutex
88+ requests []provider.Request
89+}
90+
91+func (c *subagentProactiveClient) Name() string { return "subagent-proactive-test" }
92+
93+func (c *subagentProactiveClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
94+ c.mu.Lock()
95+ c.requests = append(c.requests, req)
96+ c.mu.Unlock()
97+ events := make(chan provider.Event, 2)
98+ go func() {
99+ defer close(events)
100+ if strings.Contains(req.System, "context summarization assistant") {
101+ events <- provider.EventTextDelta{Delta: "worker summary"}
102+ events <- provider.EventDone{Stop: provider.StopEnd}
103+ return
104+ }
105+ events <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
106+ Role: provider.RoleAssistant,
107+ Content: []provider.Content{provider.TextBlock{Text: "continued same task"}},
108+ }}
109+ }()
110+ return events, nil
111+}
112+
84 func TestPromptWithContextRecoveryCompactsAndContinues(t *testing.T) {113 func TestPromptWithContextRecoveryCompactsAndContinues(t *testing.T) {
85 client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)}114 client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)}
86 agent := core.NewAgent(client, "test-model", "", nil)115 agent := core.NewAgent(client, "test-model", "", nil)
@@ -91,11 +120,14 @@ func TestPromptWithContextRecoveryCompactsAndContinues(t *testing.T) {
91 {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},120 {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
92 })121 })
93 122
94- outputStart, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil)123+ recovery, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, modes.ContextRecoveryOptions{})
95 if err != nil {124 if err != nil {
96 t.Fatalf("promptWithContextRecovery returned %v", err)125 t.Fatalf("promptWithContextRecovery returned %v", err)
97 }126 }
98- if got := finalAssistantText(agent.Messages()[outputStart:]); got != "recovered" {127+ if !recovery.Compacted {
128+ t.Fatal("promptWithContextRecovery did not report compaction")
129+ }
130+ if got := finalAssistantText(agent.Messages()[recovery.OutputStart:]); got != "recovered" {
99 t.Fatalf("final assistant text = %q, want recovered", got)131 t.Fatalf("final assistant text = %q, want recovered", got)
100 }132 }
101 133
@@ -123,7 +155,7 @@ func TestPromptWithContextRecoveryStopsAfterOneRetry(t *testing.T) {
123 agent := core.NewAgent(client, "test-model", "", nil)155 agent := core.NewAgent(client, "test-model", "", nil)
124 agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}})156 agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}})
125 157
126- _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil)158+ _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, modes.ContextRecoveryOptions{})
127 if !provider.IsContextOverflowError(err) {159 if !provider.IsContextOverflowError(err) {
128 t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err)160 t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err)
129 }161 }
@@ -147,7 +179,7 @@ func TestPromptWithContextRecoveryDoesNotCompactAfterCancellation(t *testing.T)
147 if turnEnd, ok := event.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) {179 if turnEnd, ok := event.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) {
148 cancel()180 cancel()
149 }181 }
150- }, nil)182+ }, modes.ContextRecoveryOptions{})
151 if !provider.IsContextOverflowError(err) {183 if !provider.IsContextOverflowError(err) {
152 t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err)184 t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err)
153 }185 }
@@ -170,7 +202,7 @@ func TestPromptWithContextRecoveryPropagatesCompactionFailure(t *testing.T) {
170 agent := core.NewAgent(client, "test-model", "", nil)202 agent := core.NewAgent(client, "test-model", "", nil)
171 agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}})203 agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}})
172 204
173- _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil)205+ _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, modes.ContextRecoveryOptions{})
174 if err == nil || !strings.Contains(err.Error(), "compact transcript after initial overflow") {206 if err == nil || !strings.Contains(err.Error(), "compact transcript after initial overflow") {
175 t.Fatalf("promptWithContextRecovery error = %v, want compaction failure", err)207 t.Fatalf("promptWithContextRecovery error = %v, want compaction failure", err)
176 }208 }
@@ -184,7 +216,7 @@ func TestPromptWithContextRecoveryKeepsSinglePromptIntact(t *testing.T) {
184 client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)}216 client := &subagentContextRecoveryClient{retried: make(chan provider.Request, 1)}
185 agent := core.NewAgent(client, "test-model", "", nil)217 agent := core.NewAgent(client, "test-model", "", nil)
186 218
187- _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, nil)219+ _, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, modes.ContextRecoveryOptions{})
188 if !provider.IsContextOverflowError(err) {220 if !provider.IsContextOverflowError(err) {
189 t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err)221 t.Fatalf("promptWithContextRecovery error = %v, want context overflow", err)
190 }222 }
@@ -216,8 +248,8 @@ func TestPromptWithContextRecoveryRetainsPromptWhenPersistenceFails(t *testing.T
216 agent := core.NewAgent(client, "test-model", "", nil)248 agent := core.NewAgent(client, "test-model", "", nil)
217 agent.SetMessages(history)249 agent.SetMessages(history)
218 250
219- outputStart, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, func([]provider.Message) error {251+ recovery, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, modes.ContextRecoveryOptions{
220- return errors.New("persist failed")252+ PersistCompaction: func([]provider.Message) error { return errors.New("persist failed") },
221 })253 })
222 if err == nil || !strings.Contains(err.Error(), "persist compacted transcript") {254 if err == nil || !strings.Contains(err.Error(), "persist compacted transcript") {
223 t.Fatalf("promptWithContextRecovery error = %v, want persistence failure", err)255 t.Fatalf("promptWithContextRecovery error = %v, want persistence failure", err)
@@ -225,7 +257,9 @@ func TestPromptWithContextRecoveryRetainsPromptWhenPersistenceFails(t *testing.T
225 if got := workerRequestMessageTextCount(agent.Messages(), "prior context"); got != 1 || workerRequestMessageTextCount(agent.Messages(), "finish the task") != 1 {257 if got := workerRequestMessageTextCount(agent.Messages(), "prior context"); got != 1 || workerRequestMessageTextCount(agent.Messages(), "finish the task") != 1 {
226 t.Fatalf("agent transcript lost the pending task after persistence failure: %#v", agent.Messages())258 t.Fatalf("agent transcript lost the pending task after persistence failure: %#v", agent.Messages())
227 }259 }
228- WriteNewTranscript(agent, session, outputStart)260+ if err := WriteNewTranscript(agent, session, recovery.OutputStart); err != nil {
261+ t.Fatalf("persist transcript: %v", err)
262+ }
229 if err := session.Close(); err != nil {263 if err := session.Close(); err != nil {
230 t.Fatalf("close session: %v", err)264 t.Fatalf("close session: %v", err)
231 }265 }
@@ -269,11 +303,15 @@ func TestPromptWithContextRecoveryPersistsCompaction(t *testing.T) {
269 303
270 agent := core.NewAgent(client, "test-model", "", nil)304 agent := core.NewAgent(client, "test-model", "", nil)
271 agent.SetMessages(history)305 agent.SetMessages(history)
272- outputStart, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, session.AppendCompaction)306+ recovery, err := promptWithContextRecovery(context.Background(), agent, "finish the task", nil, modes.ContextRecoveryOptions{
307+ PersistCompaction: session.AppendCompaction,
308+ })
273 if err != nil {309 if err != nil {
274 t.Fatalf("promptWithContextRecovery returned %v", err)310 t.Fatalf("promptWithContextRecovery returned %v", err)
275 }311 }
276- WriteNewTranscript(agent, session, outputStart)312+ if err := WriteNewTranscript(agent, session, recovery.OutputStart); err != nil {
313+ t.Fatalf("persist transcript: %v", err)
314+ }
277 if err := session.Close(); err != nil {315 if err := session.Close(); err != nil {
278 t.Fatalf("close session: %v", err)316 t.Fatalf("close session: %v", err)
279 }317 }
@@ -315,6 +353,299 @@ func TestPromptWithContextRecoveryPersistsCompaction(t *testing.T) {
315 }353 }
316 }354 }
317 355
356+func TestWorkerUsesConfiguredAutoCompactionThreshold(t *testing.T) {
357+ t.Setenv("ZUT_HOME", t.TempDir())
358+ threshold := 70
359+ if err := SaveConfig(Config{AutoCompactThreshold: &threshold}); err != nil {
360+ t.Fatalf("save config: %v", err)
361+ }
362+ cfg, err := loadSubagentWorkerConfig()
363+ if err != nil {
364+ t.Fatalf("load worker config: %v", err)
365+ }
366+
367+ client := &subagentProactiveClient{}
368+ agent := core.NewAgent(client, "model", "", nil)
369+ agent.SetMessages([]provider.Message{
370+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "history"}}},
371+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "answer"}}},
372+ })
373+ agent.SeedLastTurnUsage(provider.Usage{CacheReadTokens: 75})
374+ recovery, err := promptWithContextRecovery(context.Background(), agent, "finish exact task", nil, modes.ContextRecoveryOptions{
375+ ContextWindow: 100,
376+ AutoCompactThreshold: cfg.AutoCompactThreshold,
377+ })
378+ if err != nil {
379+ t.Fatalf("promptWithContextRecovery: %v", err)
380+ }
381+ if got := finalAssistantText(agent.Messages()[recovery.OutputStart:]); got != "continued same task" {
382+ t.Fatalf("continued output = %q", got)
383+ }
384+ client.mu.Lock()
385+ requests := append([]provider.Request(nil), client.requests...)
386+ client.mu.Unlock()
387+ if len(requests) != 2 || workerRequestUserTextCount(requests[1], "finish exact task") != 1 {
388+ t.Fatalf("requests = %#v, want compaction then the same task once", requests)
389+ }
390+}
391+
392+func TestWorkerConfigLoadRejectsMalformedConfig(t *testing.T) {
393+ t.Setenv("ZUT_HOME", t.TempDir())
394+ if err := os.MkdirAll(filepath.Dir(ConfigPath()), 0o755); err != nil {
395+ t.Fatal(err)
396+ }
397+ if err := os.WriteFile(ConfigPath(), []byte("{malformed"), 0o600); err != nil {
398+ t.Fatal(err)
399+ }
400+ if _, err := loadSubagentWorkerConfig(); err == nil || !strings.Contains(err.Error(), "load subagent worker config") {
401+ t.Fatalf("loadSubagentWorkerConfig error = %v, want parse failure", err)
402+ }
403+}
404+
405+type subagentSummaryUsageClient struct{}
406+
407+func (c *subagentSummaryUsageClient) Name() string { return "subagent-summary-usage-test" }
408+
409+func (c *subagentSummaryUsageClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
410+ events := make(chan provider.Event, 3)
411+ go func() {
412+ defer close(events)
413+ if strings.Contains(req.System, "context summarization assistant") {
414+ events <- provider.EventUsage{Usage: provider.Usage{InputTokens: 3, OutputTokens: 1, CostUSD: 0.03}}
415+ events <- provider.EventTextDelta{Delta: "worker summary"}
416+ events <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
417+ Role: provider.RoleAssistant,
418+ Content: []provider.Content{provider.TextBlock{Text: "worker summary"}},
419+ }}
420+ return
421+ }
422+ events <- provider.EventUsage{Usage: provider.Usage{InputTokens: 5, OutputTokens: 2, CostUSD: 0.05}}
423+ events <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
424+ Role: provider.RoleAssistant,
425+ Content: []provider.Content{provider.TextBlock{Text: "continued"}},
426+ }}
427+ }()
428+ return events, nil
429+}
430+
431+func TestPersistWorkerSummaryUsageReopensWithCumulativeUsage(t *testing.T) {
432+ session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
433+ if err != nil {
434+ t.Fatal(err)
435+ }
436+ history := []provider.Message{
437+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "old task"}}},
438+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "old answer"}}},
439+ }
440+ for _, message := range history {
441+ if err := session.AppendMessage(message); err != nil {
442+ t.Fatal(err)
443+ }
444+ }
445+ baseline := provider.Usage{InputTokens: 10, OutputTokens: 2, CostUSD: 0.1}
446+ if err := session.AppendUsage(baseline, baseline); err != nil {
447+ t.Fatal(err)
448+ }
449+ agent := core.NewAgent(&subagentSummaryUsageClient{}, "model", "", nil)
450+ agent.SetMessages(history)
451+ agent.SeedCost(baseline)
452+ agent.SeedLastTurnUsage(provider.Usage{CacheReadTokens: 90})
453+ recovery, err := promptWithContextRecovery(context.Background(), agent, "continue task", nil, modes.ContextRecoveryOptions{
454+ ContextWindow: 100,
455+ })
456+ if err != nil {
457+ t.Fatalf("promptWithContextRecovery: %v", err)
458+ }
459+ if recovery.OutputStart != len(history) || !recovery.Compacted {
460+ t.Fatalf("compaction recovery = %+v, want output start %d and compacted", recovery, len(history))
461+ }
462+ expected := provider.Usage{InputTokens: 18, OutputTokens: 5, CostUSD: 0.18}
463+ if !reflect.DeepEqual(agent.Cost(), expected) {
464+ t.Fatalf("worker cumulative usage = %#v, want %#v", agent.Cost(), expected)
465+ }
466+ if err := persistWorkerTranscript(context.Background(), agent, session, recovery.OutputStart, recovery.Compacted, workerTranscriptState{messages: history, cost: baseline}); err != nil {
467+ t.Fatalf("persistWorkerTranscript: %v", err)
468+ }
469+ if err := session.Close(); err != nil {
470+ t.Fatal(err)
471+ }
472+ cumulative, lastTurn, err := core.SessionUsageDetail(session.Path)
473+ if err != nil {
474+ t.Fatal(err)
475+ }
476+ if !reflect.DeepEqual(cumulative, expected) {
477+ t.Fatalf("reopened cumulative usage = %#v, want %#v", cumulative, expected)
478+ }
479+ wantLast := provider.Usage{InputTokens: 8, OutputTokens: 3, CostUSD: expected.CostUSD - baseline.CostUSD}
480+ if !reflect.DeepEqual(lastTurn, wantLast) {
481+ t.Fatalf("reopened last-turn usage = %#v, want %#v", lastTurn, wantLast)
482+ }
483+}
484+
485+func TestPersistWorkerCompactedTranscriptReopensWithTurnAndUsage(t *testing.T) {
486+ session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
487+ if err != nil {
488+ t.Fatal(err)
489+ }
490+ baselineMessage := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "baseline"}}}
491+ if err := session.AppendMessage(baselineMessage); err != nil {
492+ t.Fatal(err)
493+ }
494+ baselineUsage := provider.Usage{InputTokens: 10, OutputTokens: 2}
495+ if err := session.AppendUsage(baselineUsage, baselineUsage); err != nil {
496+ t.Fatal(err)
497+ }
498+
499+ agent := core.NewAgent(&subagentProactiveClient{}, "model", "", nil)
500+ agent.SetMessages([]provider.Message{
501+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "summary"}}},
502+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "current task"}}},
503+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "current answer"}}},
504+ })
505+ cumulative := provider.Usage{InputTokens: 30, OutputTokens: 8, CacheReadTokens: 4}
506+ agent.SeedCost(cumulative)
507+ if err := persistWorkerTranscript(context.Background(), agent, session, 1, true, workerTranscriptState{
508+ messages: []provider.Message{baselineMessage},
509+ cost: baselineUsage,
510+ }); err != nil {
511+ t.Fatalf("persistWorkerTranscript: %v", err)
512+ }
513+ if err := session.Close(); err != nil {
514+ t.Fatal(err)
515+ }
516+
517+ snapshot, err := core.ReadSessionSnapshot(session.Path)
518+ if err != nil {
519+ t.Fatal(err)
520+ }
521+ if workerRequestMessageTextCount(snapshot.Messages, "current task") != 1 || finalAssistantText(snapshot.Messages) != "current answer" {
522+ t.Fatalf("reopened messages = %#v, want full continued turn", snapshot.Messages)
523+ }
524+ if len(snapshot.UsageCheckpoints) == 0 || !reflect.DeepEqual(snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-1].Cumulative, cumulative) {
525+ t.Fatalf("usage checkpoints = %#v, want cumulative %#v", snapshot.UsageCheckpoints, cumulative)
526+ }
527+}
528+
529+func TestPersistWorkerCompactionFailureRollsBackAndKeepsSessionReopenable(t *testing.T) {
530+ session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
531+ if err != nil {
532+ t.Fatal(err)
533+ }
534+ baselineMessage := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "baseline"}}}
535+ if err := session.AppendMessage(baselineMessage); err != nil {
536+ t.Fatal(err)
537+ }
538+ baselineUsage := provider.Usage{InputTokens: 10, OutputTokens: 2}
539+ if err := session.AppendUsage(baselineUsage, baselineUsage); err != nil {
540+ t.Fatal(err)
541+ }
542+ if err := session.Close(); err != nil {
543+ t.Fatal(err)
544+ }
545+
546+ agent := core.NewAgent(&subagentProactiveClient{}, "model", "", nil)
547+ agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "unsaved compacted turn"}}}})
548+ agent.SeedCost(provider.Usage{InputTokens: 99})
549+ baseline := workerTranscriptState{messages: []provider.Message{baselineMessage}, cost: baselineUsage, lastTurn: baselineUsage}
550+ if err := persistWorkerTranscript(context.Background(), agent, session, 0, true, baseline); err == nil {
551+ t.Fatal("persistWorkerTranscript succeeded on closed session")
552+ }
553+ if !reflect.DeepEqual(agent.Messages(), baseline.messages) || !reflect.DeepEqual(agent.Cost(), baselineUsage) {
554+ t.Fatalf("agent was not rolled back: messages=%#v cost=%#v", agent.Messages(), agent.Cost())
555+ }
556+ snapshot, err := core.ReadSessionSnapshot(session.Path)
557+ if err != nil {
558+ t.Fatalf("reopen snapshot: %v", err)
559+ }
560+ if !reflect.DeepEqual(snapshot.Messages, baseline.messages) {
561+ t.Fatalf("reopened transcript = %#v, want baseline %#v", snapshot.Messages, baseline.messages)
562+ }
563+ if len(snapshot.UsageCheckpoints) != 1 || !reflect.DeepEqual(snapshot.UsageCheckpoints[0].Cumulative, baselineUsage) {
564+ t.Fatalf("reopened usage = %#v, want baseline", snapshot.UsageCheckpoints)
565+ }
566+}
567+
568+func TestPersistWorkerTranscriptSkipsCanceledNonCompactedTurn(t *testing.T) {
569+ session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
570+ if err != nil {
571+ t.Fatal(err)
572+ }
573+ baselineMessage := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "baseline"}}}
574+ if err := session.AppendMessage(baselineMessage); err != nil {
575+ t.Fatal(err)
576+ }
577+ agent := core.NewAgent(&subagentProactiveClient{}, "model", "", nil)
578+ agent.SetMessages([]provider.Message{
579+ baselineMessage,
580+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "canceled answer"}}},
581+ })
582+ ctx, cancel := context.WithCancel(context.Background())
583+ cancel()
584+ if err := persistWorkerTranscript(ctx, agent, session, 1, false, workerTranscriptState{messages: []provider.Message{baselineMessage}}); !errors.Is(err, context.Canceled) {
585+ t.Fatalf("persistWorkerTranscript error = %v, want cancellation", err)
586+ }
587+ if err := session.Close(); err != nil {
588+ t.Fatal(err)
589+ }
590+ snapshot, err := core.ReadSessionSnapshot(session.Path)
591+ if err != nil {
592+ t.Fatal(err)
593+ }
594+ if !reflect.DeepEqual(snapshot.Messages, []provider.Message{baselineMessage}) {
595+ t.Fatalf("reopened messages = %#v, want baseline only", snapshot.Messages)
596+ }
597+}
598+
599+func TestPersistWorkerCompactionHonorsCancellation(t *testing.T) {
600+ session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
601+ if err != nil {
602+ t.Fatal(err)
603+ }
604+ baselineMessage := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "baseline"}}}
605+ if err := session.AppendMessage(baselineMessage); err != nil {
606+ t.Fatal(err)
607+ }
608+ baseline := workerTranscriptState{messages: []provider.Message{baselineMessage}}
609+ agent := core.NewAgent(&subagentProactiveClient{}, "model", "", nil)
610+ agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "must not persist"}}}})
611+ ctx, cancel := context.WithCancel(context.Background())
612+ cancel()
613+ if err := persistWorkerTranscript(ctx, agent, session, 0, true, baseline); !errors.Is(err, context.Canceled) {
614+ t.Fatalf("persistWorkerTranscript error = %v, want cancellation", err)
615+ }
616+ if !reflect.DeepEqual(agent.Messages(), baseline.messages) {
617+ t.Fatalf("agent messages = %#v, want rollback %#v", agent.Messages(), baseline.messages)
618+ }
619+ if err := session.Close(); err != nil {
620+ t.Fatal(err)
621+ }
622+ snapshot, err := core.ReadSessionSnapshot(session.Path)
623+ if err != nil {
624+ t.Fatal(err)
625+ }
626+ if !reflect.DeepEqual(snapshot.Messages, baseline.messages) {
627+ t.Fatalf("reopened messages = %#v, want baseline", snapshot.Messages)
628+ }
629+}
630+
631+func TestWriteNewTranscriptReturnsPersistenceErrors(t *testing.T) {
632+ session, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
633+ if err != nil {
634+ t.Fatal(err)
635+ }
636+ if err := session.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "baseline"}}}); err != nil {
637+ t.Fatal(err)
638+ }
639+ if err := session.Close(); err != nil {
640+ t.Fatal(err)
641+ }
642+ agent := core.NewAgent(&subagentProactiveClient{}, "model", "", nil)
643+ agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "new"}}}})
644+ if err := WriteNewTranscript(agent, session, 0); err == nil || !strings.Contains(err.Error(), "append transcript message") {
645+ t.Fatalf("WriteNewTranscript error = %v, want append failure", err)
646+ }
647+}
648+
318 func workerRequestMessageTextCount(messages []provider.Message, want string) int {649 func workerRequestMessageTextCount(messages []provider.Message, want string) int {
319 count := 0650 count := 0
320 for _, message := range messages {651 for _, message := range messages {
M packages/core/agent.go +12 -1
@@ -31,6 +31,11 @@ type Agent struct {
3131 // rejects it for providers that do not support that contract.
3232 FastMode bool
3333
34+ // ContextWindow is the effective input context capacity retained from
35+ // model resolution. Hosts use it for proactive compaction even when the
36+ // model was synthesized for an open-catalog provider.
37+ ContextWindow int
38+
3439 // MaxTokens caps the model's output tokens per turn. Zero leaves
3540 // the field unset on the provider request, letting each provider
3641 // apply its own default (which can be conservative, e.g. Bedrock
@@ -342,6 +347,12 @@ func (a *Agent) Cost() provider.Usage {
342347 return a.cost.Total
343348 }
344349
350+func (a *Agent) addUsage(usage provider.Usage) provider.Usage {
351+ a.mu.Lock()
352+ defer a.mu.Unlock()
353+ return a.cost.Add(usage)
354+}
355+
345356 // SeedCost sets the cumulative usage as a baseline before the first
346357 // turn runs. Used when transferring state from another agent (model
347358 // or provider switch) so the running cost meter doesn't reset to 0.
@@ -745,7 +756,7 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
745756 case provider.EventToolEnd:
746757 sink(EvToolUseEnd{ID: e.ID})
747758 case provider.EventUsage:
748- cum := a.cost.Add(e.Usage)
759+ cum := a.addUsage(e.Usage)
749760 sink(EvUsage{Usage: e.Usage, Cumulative: cum})
750761 if a.OnUsage != nil {
751762 a.OnUsage(cum)
@@ -31,6 +31,11 @@ type Agent struct {
31 // rejects it for providers that do not support that contract.31 // rejects it for providers that do not support that contract.
32 FastMode bool32 FastMode bool
33 33
34+ // ContextWindow is the effective input context capacity retained from
35+ // model resolution. Hosts use it for proactive compaction even when the
36+ // model was synthesized for an open-catalog provider.
37+ ContextWindow int
38+
34 // MaxTokens caps the model's output tokens per turn. Zero leaves39 // MaxTokens caps the model's output tokens per turn. Zero leaves
35 // the field unset on the provider request, letting each provider40 // the field unset on the provider request, letting each provider
36 // apply its own default (which can be conservative, e.g. Bedrock41 // apply its own default (which can be conservative, e.g. Bedrock
@@ -342,6 +347,12 @@ func (a *Agent) Cost() provider.Usage {
342 return a.cost.Total347 return a.cost.Total
343 }348 }
344 349
350+func (a *Agent) addUsage(usage provider.Usage) provider.Usage {
351+ a.mu.Lock()
352+ defer a.mu.Unlock()
353+ return a.cost.Add(usage)
354+}
355+
345 // SeedCost sets the cumulative usage as a baseline before the first356 // SeedCost sets the cumulative usage as a baseline before the first
346 // turn runs. Used when transferring state from another agent (model357 // turn runs. Used when transferring state from another agent (model
347 // or provider switch) so the running cost meter doesn't reset to 0.358 // or provider switch) so the running cost meter doesn't reset to 0.
@@ -745,7 +756,7 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
745 case provider.EventToolEnd:756 case provider.EventToolEnd:
746 sink(EvToolUseEnd{ID: e.ID})757 sink(EvToolUseEnd{ID: e.ID})
747 case provider.EventUsage:758 case provider.EventUsage:
748- cum := a.cost.Add(e.Usage)759+ cum := a.addUsage(e.Usage)
749 sink(EvUsage{Usage: e.Usage, Cumulative: cum})760 sink(EvUsage{Usage: e.Usage, Cumulative: cum})
750 if a.OnUsage != nil {761 if a.OnUsage != nil {
751 a.OnUsage(cum)762 a.OnUsage(cum)
M packages/core/compact.go +8 -0
@@ -110,6 +110,14 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s
110110 if eventSink != nil {
111111 eventSink(EvTextDelta{Delta: e.Delta})
112112 }
113+ case provider.EventUsage:
114+ cum := a.addUsage(e.Usage)
115+ if eventSink != nil {
116+ eventSink(EvUsage{Usage: e.Usage, Cumulative: cum})
117+ }
118+ if a.OnUsage != nil {
119+ a.OnUsage(cum)
120+ }
113121 case provider.EventDone:
114122 if e.Err != nil {
115123 return "", e.Err
@@ -110,6 +110,14 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s
110 if eventSink != nil {110 if eventSink != nil {
111 eventSink(EvTextDelta{Delta: e.Delta})111 eventSink(EvTextDelta{Delta: e.Delta})
112 }112 }
113+ case provider.EventUsage:
114+ cum := a.addUsage(e.Usage)
115+ if eventSink != nil {
116+ eventSink(EvUsage{Usage: e.Usage, Cumulative: cum})
117+ }
118+ if a.OnUsage != nil {
119+ a.OnUsage(cum)
120+ }
113 case provider.EventDone:121 case provider.EventDone:
114 if e.Err != nil {122 if e.Err != nil {
115 return "", e.Err123 return "", e.Err
M packages/core/compact_lifecycle_test.go +50 -1
@@ -12,6 +12,7 @@ type compactLifecycleClient struct {
1212 hadLifecycle bool
1313 req provider.Request
1414 summary string
15+ usage []provider.Usage
1516 }
1617
1718 func (c *compactLifecycleClient) Name() string { return "compact-lifecycle" }
@@ -26,8 +27,11 @@ func (c *compactLifecycleClient) Stream(_ context.Context, req provider.Request)
2627 if summary == "" {
2728 summary = "summary"
2829 }
29- out := make(chan provider.Event, 2)
30+ out := make(chan provider.Event, 2+len(c.usage))
3031 out <- provider.EventTextDelta{Delta: summary}
32+ for _, usage := range c.usage {
33+ out <- provider.EventUsage{Usage: usage}
34+ }
3135 out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
3236 Role: provider.RoleAssistant,
3337 Content: []provider.Content{provider.TextBlock{Text: summary}},
@@ -36,6 +40,51 @@ func (c *compactLifecycleClient) Stream(_ context.Context, req provider.Request)
3640 return out, nil
3741 }
3842
43+func TestCompactWithEventsAccumulatesAndPersistsUsage(t *testing.T) {
44+ client := &compactLifecycleClient{usage: []provider.Usage{{InputTokens: 2}, {OutputTokens: 3, CostUSD: 0.25}}}
45+ agent := NewAgent(client, "compact-model", "system", Registry{})
46+ agent.SetMessages([]provider.Message{{
47+ Role: provider.RoleUser,
48+ Content: []provider.Content{provider.TextBlock{Text: "source"}},
49+ }})
50+ root := t.TempDir()
51+ sess, err := NewSession(root, t.TempDir(), "provider", "compact-model", "test")
52+ if err != nil {
53+ t.Fatal(err)
54+ }
55+ t.Cleanup(func() { _ = sess.Close() })
56+ agent.OnUsage = func(cumulative provider.Usage) {
57+ if err := sess.AppendUsage(cumulative, cumulative); err != nil {
58+ t.Fatalf("persist usage: %v", err)
59+ }
60+ }
61+ var events []AgentEvent
62+ if _, err := agent.CompactWithEvents(context.Background(), 0, func(event AgentEvent) {
63+ events = append(events, event)
64+ }); err != nil {
65+ t.Fatalf("CompactWithEvents: %v", err)
66+ }
67+ if got, want := agent.Cost(), (provider.Usage{InputTokens: 2, OutputTokens: 3, CostUSD: 0.25}); got != want {
68+ t.Fatalf("agent cost = %+v, want %+v", got, want)
69+ }
70+ cum, _, err := SessionUsageDetail(sess.Path)
71+ if err != nil {
72+ t.Fatal(err)
73+ }
74+ if cum != agent.Cost() {
75+ t.Fatalf("persisted cumulative usage = %+v, want %+v", cum, agent.Cost())
76+ }
77+ var cumulative []provider.Usage
78+ for _, event := range events {
79+ if usage, ok := event.(EvUsage); ok {
80+ cumulative = append(cumulative, usage.Cumulative)
81+ }
82+ }
83+ if len(cumulative) != 2 || cumulative[0].InputTokens != 2 || cumulative[1] != agent.Cost() {
84+ t.Fatalf("usage event cumulative values = %+v, want first input=2 and final=%+v", cumulative, agent.Cost())
85+ }
86+}
87+
3988 func TestCompactWithEventsEmitsRequestLifecycle(t *testing.T) {
4089 client := &compactLifecycleClient{}
4190 agent := NewAgent(client, "compact-model", "system", Registry{})
@@ -12,6 +12,7 @@ type compactLifecycleClient struct {
12 hadLifecycle bool12 hadLifecycle bool
13 req provider.Request13 req provider.Request
14 summary string14 summary string
15+ usage []provider.Usage
15 }16 }
16 17
17 func (c *compactLifecycleClient) Name() string { return "compact-lifecycle" }18 func (c *compactLifecycleClient) Name() string { return "compact-lifecycle" }
@@ -26,8 +27,11 @@ func (c *compactLifecycleClient) Stream(_ context.Context, req provider.Request)
26 if summary == "" {27 if summary == "" {
27 summary = "summary"28 summary = "summary"
28 }29 }
29- out := make(chan provider.Event, 2)30+ out := make(chan provider.Event, 2+len(c.usage))
30 out <- provider.EventTextDelta{Delta: summary}31 out <- provider.EventTextDelta{Delta: summary}
32+ for _, usage := range c.usage {
33+ out <- provider.EventUsage{Usage: usage}
34+ }
31 out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{35 out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
32 Role: provider.RoleAssistant,36 Role: provider.RoleAssistant,
33 Content: []provider.Content{provider.TextBlock{Text: summary}},37 Content: []provider.Content{provider.TextBlock{Text: summary}},
@@ -36,6 +40,51 @@ func (c *compactLifecycleClient) Stream(_ context.Context, req provider.Request)
36 return out, nil40 return out, nil
37 }41 }
38 42
43+func TestCompactWithEventsAccumulatesAndPersistsUsage(t *testing.T) {
44+ client := &compactLifecycleClient{usage: []provider.Usage{{InputTokens: 2}, {OutputTokens: 3, CostUSD: 0.25}}}
45+ agent := NewAgent(client, "compact-model", "system", Registry{})
46+ agent.SetMessages([]provider.Message{{
47+ Role: provider.RoleUser,
48+ Content: []provider.Content{provider.TextBlock{Text: "source"}},
49+ }})
50+ root := t.TempDir()
51+ sess, err := NewSession(root, t.TempDir(), "provider", "compact-model", "test")
52+ if err != nil {
53+ t.Fatal(err)
54+ }
55+ t.Cleanup(func() { _ = sess.Close() })
56+ agent.OnUsage = func(cumulative provider.Usage) {
57+ if err := sess.AppendUsage(cumulative, cumulative); err != nil {
58+ t.Fatalf("persist usage: %v", err)
59+ }
60+ }
61+ var events []AgentEvent
62+ if _, err := agent.CompactWithEvents(context.Background(), 0, func(event AgentEvent) {
63+ events = append(events, event)
64+ }); err != nil {
65+ t.Fatalf("CompactWithEvents: %v", err)
66+ }
67+ if got, want := agent.Cost(), (provider.Usage{InputTokens: 2, OutputTokens: 3, CostUSD: 0.25}); got != want {
68+ t.Fatalf("agent cost = %+v, want %+v", got, want)
69+ }
70+ cum, _, err := SessionUsageDetail(sess.Path)
71+ if err != nil {
72+ t.Fatal(err)
73+ }
74+ if cum != agent.Cost() {
75+ t.Fatalf("persisted cumulative usage = %+v, want %+v", cum, agent.Cost())
76+ }
77+ var cumulative []provider.Usage
78+ for _, event := range events {
79+ if usage, ok := event.(EvUsage); ok {
80+ cumulative = append(cumulative, usage.Cumulative)
81+ }
82+ }
83+ if len(cumulative) != 2 || cumulative[0].InputTokens != 2 || cumulative[1] != agent.Cost() {
84+ t.Fatalf("usage event cumulative values = %+v, want first input=2 and final=%+v", cumulative, agent.Cost())
85+ }
86+}
87+
39 func TestCompactWithEventsEmitsRequestLifecycle(t *testing.T) {88 func TestCompactWithEventsEmitsRequestLifecycle(t *testing.T) {
40 client := &compactLifecycleClient{}89 client := &compactLifecycleClient{}
41 agent := NewAgent(client, "compact-model", "system", Registry{})90 agent := NewAgent(client, "compact-model", "system", Registry{})
M packages/core/session.go +61 -1
@@ -359,6 +359,17 @@ func readSessionSnapshot(ctx context.Context, path string) (SessionSnapshot, err
359359 }
360360 snapshot.Messages = compacted
361361 generation++
362+ cumulative, err := hydrateCompactionUsage(line)
363+ if err != nil {
364+ return fmt.Errorf("line %d: invalid compaction usage: %w", lineNo, err)
365+ }
366+ if cumulative != nil {
367+ rawCheckpoints = append(rawCheckpoints, rawCheckpoint{
368+ messageCount: len(snapshot.Messages),
369+ cumulative: *cumulative,
370+ generation: generation,
371+ })
372+ }
362373
363374 case "usage":
364375 var row struct {
@@ -524,6 +535,16 @@ func ReadSessionHistory(path string) (SessionHistory, error) {
524535 }
525536 appendCurrent()
526537 current = rawSegment{compacted: true, messages: compacted}
538+ cumulative, err := hydrateCompactionUsage(line)
539+ if err != nil {
540+ return fmt.Errorf("line %d: invalid compaction usage: %w", lineNo, err)
541+ }
542+ if cumulative != nil {
543+ current.checkpoints = append(current.checkpoints, rawCheckpoint{
544+ messageCount: len(current.messages),
545+ cumulative: *cumulative,
546+ })
547+ }
527548
528549 case "usage":
529550 var row struct {
@@ -1198,6 +1219,27 @@ func (s *Session) AppendCompaction(messages []provider.Message) error {
11981219 return nil
11991220 }
12001221
1222+// AppendCompactionWithUsage atomically records a replacement transcript and
1223+// its cumulative usage in one JSONL row. Workers use this after compaction so
1224+// a persistence failure cannot leave a resumable session with only part of the
1225+// continued turn or without its matching usage checkpoint.
1226+func (s *Session) AppendCompactionWithUsage(messages []provider.Message, cumulative provider.Usage) error {
1227+ if s == nil {
1228+ return nil
1229+ }
1230+ compactionMessages := messages
1231+ if err := s.writeLine(sessionLine{
1232+ Type: "compaction",
1233+ Messages: &compactionMessages,
1234+ Usage: &cumulative,
1235+ Cumulative: &cumulative,
1236+ }); err != nil {
1237+ return err
1238+ }
1239+ s.messagesAppended++
1240+ return nil
1241+}
1242+
12011243 // UpdateModel records a provider/model switch in the session file.
12021244 // The reader keeps the most recent meta entry, so the session resumes
12031245 // with the updated model.
@@ -1298,6 +1340,14 @@ func (s *Session) AppendExtensionState(extension string, state json.RawMessage)
12981340 return nil
12991341 }
13001342
1343+// Flush writes buffered session data to the append handle.
1344+func (s *Session) Flush() error {
1345+ if s == nil {
1346+ return nil
1347+ }
1348+ return s.buf.Flush()
1349+}
1350+
13011351 // Close flushes and closes the session file. If the session was
13021352 // freshly created in this process and never had any messages
13031353 // appended (the user opened zut, looked around, and exited without
@@ -1307,7 +1357,7 @@ func (s *Session) Close() error {
13071357 if s == nil {
13081358 return nil
13091359 }
1310- flushErr := s.buf.Flush()
1360+ flushErr := s.Flush()
13111361 closeErr := s.writer.Close()
13121362 if s.freshFile && s.messagesAppended == 0 && len(s.ExtensionState) == 0 && len(s.Meta.CompactHandoff) == 0 {
13131363 // Best-effort cleanup. We deliberately don't propagate the
@@ -1341,6 +1391,16 @@ func (s *Session) writeLine(row sessionLine) error {
13411391 // We persist messages by reading the raw "message" object back and
13421392 // rebuilding Content from discriminated fields.
13431393
1394+func hydrateCompactionUsage(lineBytes []byte) (*provider.Usage, error) {
1395+ var row struct {
1396+ Cumulative *provider.Usage `json:"cumulative"`
1397+ }
1398+ if err := json.Unmarshal(lineBytes, &row); err != nil {
1399+ return nil, err
1400+ }
1401+ return row.Cumulative, nil
1402+}
1403+
13441404 func hydrateCompaction(lineBytes []byte) ([]provider.Message, error) {
13451405 var row struct {
13461406 Messages json.RawMessage `json:"messages"`
@@ -359,6 +359,17 @@ func readSessionSnapshot(ctx context.Context, path string) (SessionSnapshot, err
359 }359 }
360 snapshot.Messages = compacted360 snapshot.Messages = compacted
361 generation++361 generation++
362+ cumulative, err := hydrateCompactionUsage(line)
363+ if err != nil {
364+ return fmt.Errorf("line %d: invalid compaction usage: %w", lineNo, err)
365+ }
366+ if cumulative != nil {
367+ rawCheckpoints = append(rawCheckpoints, rawCheckpoint{
368+ messageCount: len(snapshot.Messages),
369+ cumulative: *cumulative,
370+ generation: generation,
371+ })
372+ }
362 373
363 case "usage":374 case "usage":
364 var row struct {375 var row struct {
@@ -524,6 +535,16 @@ func ReadSessionHistory(path string) (SessionHistory, error) {
524 }535 }
525 appendCurrent()536 appendCurrent()
526 current = rawSegment{compacted: true, messages: compacted}537 current = rawSegment{compacted: true, messages: compacted}
538+ cumulative, err := hydrateCompactionUsage(line)
539+ if err != nil {
540+ return fmt.Errorf("line %d: invalid compaction usage: %w", lineNo, err)
541+ }
542+ if cumulative != nil {
543+ current.checkpoints = append(current.checkpoints, rawCheckpoint{
544+ messageCount: len(current.messages),
545+ cumulative: *cumulative,
546+ })
547+ }
527 548
528 case "usage":549 case "usage":
529 var row struct {550 var row struct {
@@ -1198,6 +1219,27 @@ func (s *Session) AppendCompaction(messages []provider.Message) error {
1198 return nil1219 return nil
1199 }1220 }
1200 1221
1222+// AppendCompactionWithUsage atomically records a replacement transcript and
1223+// its cumulative usage in one JSONL row. Workers use this after compaction so
1224+// a persistence failure cannot leave a resumable session with only part of the
1225+// continued turn or without its matching usage checkpoint.
1226+func (s *Session) AppendCompactionWithUsage(messages []provider.Message, cumulative provider.Usage) error {
1227+ if s == nil {
1228+ return nil
1229+ }
1230+ compactionMessages := messages
1231+ if err := s.writeLine(sessionLine{
1232+ Type: "compaction",
1233+ Messages: &compactionMessages,
1234+ Usage: &cumulative,
1235+ Cumulative: &cumulative,
1236+ }); err != nil {
1237+ return err
1238+ }
1239+ s.messagesAppended++
1240+ return nil
1241+}
1242+
1201 // UpdateModel records a provider/model switch in the session file.1243 // UpdateModel records a provider/model switch in the session file.
1202 // The reader keeps the most recent meta entry, so the session resumes1244 // The reader keeps the most recent meta entry, so the session resumes
1203 // with the updated model.1245 // with the updated model.
@@ -1298,6 +1340,14 @@ func (s *Session) AppendExtensionState(extension string, state json.RawMessage)
1298 return nil1340 return nil
1299 }1341 }
1300 1342
1343+// Flush writes buffered session data to the append handle.
1344+func (s *Session) Flush() error {
1345+ if s == nil {
1346+ return nil
1347+ }
1348+ return s.buf.Flush()
1349+}
1350+
1301 // Close flushes and closes the session file. If the session was1351 // Close flushes and closes the session file. If the session was
1302 // freshly created in this process and never had any messages1352 // freshly created in this process and never had any messages
1303 // appended (the user opened zut, looked around, and exited without1353 // appended (the user opened zut, looked around, and exited without
@@ -1307,7 +1357,7 @@ func (s *Session) Close() error {
1307 if s == nil {1357 if s == nil {
1308 return nil1358 return nil
1309 }1359 }
1310- flushErr := s.buf.Flush()1360+ flushErr := s.Flush()
1311 closeErr := s.writer.Close()1361 closeErr := s.writer.Close()
1312 if s.freshFile && s.messagesAppended == 0 && len(s.ExtensionState) == 0 && len(s.Meta.CompactHandoff) == 0 {1362 if s.freshFile && s.messagesAppended == 0 && len(s.ExtensionState) == 0 && len(s.Meta.CompactHandoff) == 0 {
1313 // Best-effort cleanup. We deliberately don't propagate the1363 // Best-effort cleanup. We deliberately don't propagate the
@@ -1341,6 +1391,16 @@ func (s *Session) writeLine(row sessionLine) error {
1341 // We persist messages by reading the raw "message" object back and1391 // We persist messages by reading the raw "message" object back and
1342 // rebuilding Content from discriminated fields.1392 // rebuilding Content from discriminated fields.
1343 1393
1394+func hydrateCompactionUsage(lineBytes []byte) (*provider.Usage, error) {
1395+ var row struct {
1396+ Cumulative *provider.Usage `json:"cumulative"`
1397+ }
1398+ if err := json.Unmarshal(lineBytes, &row); err != nil {
1399+ return nil, err
1400+ }
1401+ return row.Cumulative, nil
1402+}
1403+
1344 func hydrateCompaction(lineBytes []byte) ([]provider.Message, error) {1404 func hydrateCompaction(lineBytes []byte) ([]provider.Message, error) {
1345 var row struct {1405 var row struct {
1346 Messages json.RawMessage `json:"messages"`1406 Messages json.RawMessage `json:"messages"`

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close