| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "slices" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | "time" |
| 10 | "unicode/utf8" |
| 11 | |
| 12 | "reasonix/internal/event" |
| 13 | "reasonix/internal/provider" |
| 14 | "reasonix/internal/tool" |
| 15 | ) |
| 16 | |
| 17 | // foldableSessionOverForce builds a transcript whose bulk is assistant text, so |
| 18 | // the free prune pass cannot reclaim it and Prepare must reach the summarizer. |
| 19 | func foldableSessionOverForce(turns int) *Session { |
| 20 | big := strings.Repeat("word ", 400) |
| 21 | msgs := []provider.Message{ |
| 22 | {Role: provider.RoleSystem, Content: "sys"}, |
| 23 | {Role: provider.RoleUser, Content: "standing constraint: never change the public API"}, |
| 24 | } |
| 25 | for range turns { |
| 26 | msgs = append(msgs, |
| 27 | provider.Message{Role: provider.RoleAssistant, Content: big}, |
| 28 | provider.Message{Role: provider.RoleUser, Content: "continue"}, |
| 29 | ) |
| 30 | } |
| 31 | return &Session{Messages: msgs} |
| 32 | } |
| 33 | |
| 34 | func agentOverForce(t *testing.T, prov provider.Provider, sess *Session) *Agent { |
| 35 | t.Helper() |
| 36 | return agentOverForceWindow(t, prov, sess, 5000) |
| 37 | } |
| 38 | |
| 39 | // agentOverForceWindow sits the session above the force ratio. A folded |
| 40 | // transcript lands back under the trigger, so a blocked turn can only mean the |
| 41 | // fold itself failed. |
| 42 | func agentOverForceWindow(t *testing.T, prov provider.Provider, sess *Session, window int) *Agent { |
| 43 | t.Helper() |
| 44 | return New(prov, tool.NewRegistry(), sess, Options{ |
| 45 | ContextWindow: window, |
| 46 | CompactRatio: 0.5, |
| 47 | CompactForceRatio: 0.5, |
| 48 | RecentKeep: 2, |
| 49 | ArchiveDir: t.TempDir(), |
| 50 | }, event.Discard) |
| 51 | } |
| 52 | |
| 53 | // degradedFold reports whether a fold was committed with the mechanical digest |
| 54 | // standing in for the summary. The receipt is the host record that the |
| 55 | // projection was installed; the digest text is what the model is actually told. |
| 56 | func degradedFold(a *Agent) bool { |
| 57 | r := a.sess.compactionState.LastReceipt |
| 58 | return r != nil && r.Status == "applied" && |
| 59 | strings.Contains(latestDigest(a.sess.compactionState.Projection.Messages), "summary was unavailable") |
| 60 | } |
| 61 | |
| 62 | func prepareContext(ctx context.Context, a *Agent, trigger string) error { |
| 63 | _, err := a.contextManager().Prepare(ctx, ContextPreparePolicy{Trigger: trigger}) |
| 64 | return err |
| 65 | } |
| 66 | |
| 67 | // foldRegionOf is the region the next compaction would hand the summarizer. |
| 68 | func foldRegionOf(a *Agent) []provider.Message { |
| 69 | canonical, version := a.sess.conversation.snapshotMessagesVersion() |
| 70 | msgs, _ := a.visibleInputForFold(a.sess.compactionState, canonical, version) |
| 71 | head, start, ok := a.planFoldRegion(msgs, false, false) |
| 72 | if !ok { |
| 73 | return nil |
| 74 | } |
| 75 | _, fold, _ := a.partitionFoldForProjection(msgs[head:start]) |
| 76 | return fold |
| 77 | } |
| 78 | |
| 79 | // latestDigest returns the text of the last compaction digest in a projection. |
| 80 | func latestDigest(msgs []provider.Message) string { |
| 81 | for _, m := range slices.Backward(msgs) { |
| 82 | if isCompactionSummary(m) { |
| 83 | return m.Content |
| 84 | } |
| 85 | } |
| 86 | return "" |
| 87 | } |
| 88 | |
| 89 | // projectionTokens reports what the model would actually see. |
| 90 | func projectionTokens(a *Agent) int { |
| 91 | msgs, _ := a.sess.conversation.snapshotMessagesVersion() |
| 92 | return estimateMessagesTokens(provider.ModelMessages(modelVisibleFromProjection(a.sess.compactionState.Projection, msgs))) |
| 93 | } |
| 94 | |
| 95 | func TestSummarizerCancellationAtOverflowPropagatesWithoutFallback(t *testing.T) { |
| 96 | sess := foldableSessionOverForce(6) |
| 97 | a := agentOverForce(t, &fakeProvider{hang: true}, sess) |
| 98 | before := estimateMessagesTokens(provider.ModelMessages(sess.Messages)) |
| 99 | |
| 100 | ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) |
| 101 | defer cancel() |
| 102 | |
| 103 | if err := prepareContext(ctx, a, CompactionTriggerOverflow); !errors.Is(err, context.DeadlineExceeded) { |
| 104 | t.Fatalf("prepare = %v, want context deadline", err) |
| 105 | } |
| 106 | if after := projectionTokens(a); after != 0 { |
| 107 | t.Fatalf("cancellation installed projection tokens=%d (source=%d)", after, before) |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | // Thinking-mode providers (DeepSeek vision SKUs) may answer the summary |
| 112 | // request with reasoning_content only and an empty content block. The |
| 113 | // summarizer must surface the reasoning instead of failing with "summarizer |
| 114 | // returned empty output" and retrying forever (observed on a 2M-token session: |
| 115 | // chunked fallback reached fragment 2/14 and died on the same empty-output |
| 116 | // check). |
| 117 | func TestSummarizerReasoningOnlyIsSurfacedNotEmptied(t *testing.T) { |
| 118 | sess := foldableSessionOverForce(6) |
| 119 | a := agentOverForce(t, &fakeProvider{reasoningReply: "- kept: alpha constraint\n- kept: beta file path"}, sess) |
| 120 | before := estimateMessagesTokens(provider.ModelMessages(sess.Messages)) |
| 121 | |
| 122 | if err := prepareContext(context.Background(), a, CompactionTriggerOverflow); err != nil { |
| 123 | t.Fatalf("prepare with reasoning-only summary = %v, want applied fold", err) |
| 124 | } |
| 125 | if after := projectionTokens(a); after == 0 || after >= before { |
| 126 | t.Fatalf("reasoning-only summary installed projection tokens=%d (source=%d)", after, before) |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | // A reasoning-only reply that also opened a tool call is not a briefing: the |
| 131 | // empty-output rejection must survive, and an opened call counts even when |
| 132 | // the stream never completed it. At the ceiling that rejection now ends in the |
| 133 | // truncation rescue instead of a digest built from chain-of-thought. |
| 134 | func TestSummarizerReasoningWithToolCallStaysEmpty(t *testing.T) { |
| 135 | sess := foldableSessionOverForce(6) |
| 136 | a := agentOverForce(t, &fakeProvider{reasoningReply: "let me call a tool first", reasoningTool: true}, sess) |
| 137 | var rejected string |
| 138 | a.svc.sink = event.FuncSink(func(e event.Event) { |
| 139 | if e.Kind == event.ContextMaintenanceEvent && e.Maintenance != nil && e.Maintenance.Status == "failed" { |
| 140 | rejected = e.Maintenance.Reason |
| 141 | } |
| 142 | }) |
| 143 | |
| 144 | if err := prepareContext(context.Background(), a, CompactionTriggerOverflow); err != nil { |
| 145 | t.Fatalf("prepare = %v, want the truncation rescue after the empty-output rejection", err) |
| 146 | } |
| 147 | if !strings.Contains(rejected, "summarizer returned empty output") { |
| 148 | t.Fatalf("failed receipt reason = %q, want the empty-output rejection for reasoning with a tool call", rejected) |
| 149 | } |
| 150 | if !truncatedRescue(a) { |
| 151 | t.Fatalf("receipt = %+v, want a truncation rescue and no digest built from tool-call reasoning", a.sess.compactionState.LastReceipt) |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | // The reasoning clamp cuts on rune boundaries so a CJK briefing stays valid |
| 156 | // UTF-8 for the provider request that replays the digest. |
| 157 | func TestSummarizerReasoningClampKeepsValidUTF8(t *testing.T) { |
| 158 | sess := foldableSessionOverForce(6) |
| 159 | a := agentOverForce(t, &fakeProvider{reasoningReply: strings.Repeat("上下文摘要要点。", 3000)}, sess) |
| 160 | |
| 161 | summary, _, err := a.summarize(context.Background(), sess.Messages[1:], "") |
| 162 | if err != nil { |
| 163 | t.Fatalf("summarize = %v", err) |
| 164 | } |
| 165 | if len(summary) > summaryReasoningMaxBytes || !utf8.ValidString(summary) { |
| 166 | t.Fatalf("clamped reasoning is %d bytes valid=%v, want <= %d bytes of valid UTF-8", len(summary), utf8.ValidString(summary), summaryReasoningMaxBytes) |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | // truncatedRescue reports whether the last maintenance installed the lossy |
| 171 | // truncation projection instead of any digest. |
| 172 | func truncatedRescue(a *Agent) bool { |
| 173 | r := a.sess.compactionState.LastReceipt |
| 174 | return r != nil && r.Status == "applied" && r.Action == maintenanceActionTruncate && |
| 175 | latestDigest(a.sess.compactionState.Projection.Messages) == "" |
| 176 | } |
| 177 | |
| 178 | // Overflow is where a failed summary used to turn into "context exceeds |
| 179 | // provider limit and compaction failed". It now falls back to the truncation |
| 180 | // rescue: no digest is fabricated, but the turn leaves with a smaller view. |
| 181 | func TestOverflowSummarizerFailureFallsBackToTruncation(t *testing.T) { |
| 182 | sess := foldableSessionOverForce(6) |
| 183 | a := agentOverForce(t, &fakeProvider{streamErr: errors.New("provider down")}, sess) |
| 184 | before := estimateMessagesTokens(provider.ModelMessages(sess.Messages)) |
| 185 | |
| 186 | if err := prepareContext(context.Background(), a, CompactionTriggerOverflow); err != nil { |
| 187 | t.Fatalf("prepare = %v, want the truncation rescue", err) |
| 188 | } |
| 189 | if !truncatedRescue(a) { |
| 190 | t.Fatalf("receipt = %+v, want an applied truncation without a digest", a.sess.compactionState.LastReceipt) |
| 191 | } |
| 192 | if after := projectionTokens(a); after == 0 || after >= before { |
| 193 | t.Fatalf("truncation left projection tokens=%d (source=%d)", after, before) |
| 194 | } |
| 195 | if after, hard := a.ContextUsedTokens(), a.hardInputCeiling(); after >= hard { |
| 196 | t.Fatalf("truncated view estimates %d tokens against a %d ceiling", after, hard) |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | // An oversized complete-prefix request fails admission and must not fabricate |
| 201 | // a summary or privately shorten its input; only the explicit truncation |
| 202 | // rescue may change the view. |
| 203 | func TestSummarizerFailureOnOversizedFoldDoesNotFabricateDigest(t *testing.T) { |
| 204 | sess := foldableSessionOverForce(120) |
| 205 | a := agentOverForceWindow(t, &fakeProvider{streamErr: errors.New("provider exploded")}, sess, 60000) |
| 206 | if tokens, budget := a.guardedSummaryInputTokens(foldRegionOf(a)), a.summaryInputBudget(""); budget <= 0 || tokens <= budget { |
| 207 | t.Fatalf("fixture fold is %d tokens against a %d budget; the shortening path is not exercised", tokens, budget) |
| 208 | } |
| 209 | |
| 210 | if err := prepareContext(context.Background(), a, CompactionTriggerOverflow); err != nil { |
| 211 | t.Fatalf("prepare = %v, want the truncation rescue", err) |
| 212 | } |
| 213 | if degradedFold(a) || latestDigest(a.sess.compactionState.Projection.Messages) != "" { |
| 214 | t.Errorf("failed summary fabricated a digest: receipt=%+v", a.sess.compactionState.LastReceipt) |
| 215 | } |
| 216 | if !truncatedRescue(a) { |
| 217 | t.Fatalf("receipt = %+v, want an applied truncation", a.sess.compactionState.LastReceipt) |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | // Below the hard ceiling the turn still goes out, so a failed summary must stay |
| 222 | // a failure: the recoverable view proceeds unchanged below the hard ceiling. |
| 223 | func TestPressureBelowHardCeilingKeepsTheFailure(t *testing.T) { |
| 224 | sess := foldableSessionOverForce(6) |
| 225 | a := agentOverForce(t, &fakeProvider{streamErr: errors.New("provider down")}, sess) |
| 226 | if est, hard := a.estimatedPromptTokens(sess.Messages), a.hardInputCeiling(); est >= hard { |
| 227 | t.Fatalf("fixture estimates %d tokens against a %d ceiling; it is not below it", est, hard) |
| 228 | } |
| 229 | |
| 230 | if err := prepareContext(context.Background(), a, CompactionTriggerPressure); err != nil { |
| 231 | t.Fatalf("prepare = %v, want the turn to proceed unfolded", err) |
| 232 | } |
| 233 | if degradedFold(a) { |
| 234 | t.Error("a recoverable view was folded without a summary") |
| 235 | } |
| 236 | if r := a.sess.compactionState.LastReceipt; r == nil || (r.Status != "blocked" && r.Status != "failed") { |
| 237 | t.Errorf("receipt = %+v, want the failure recorded so the summary is not paid for twice", r) |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | // The receipt recorded below the ceiling must not outlive the ceiling itself: |
| 242 | // once growing usage crosses the hard ceiling the fold is the only way out, so |
| 243 | // recovery has to run even with a standing failed receipt. If the summarizer is |
| 244 | // still down, hard pressure takes the truncation rescue without a fake digest. |
| 245 | func TestFailedSummaryReceiptRetriesAtHardCeilingWithoutFallback(t *testing.T) { |
| 246 | sess := foldableSessionOverForce(6) |
| 247 | a := agentOverForce(t, &fakeProvider{streamErr: errors.New("provider down")}, sess) |
| 248 | a.activeTurnCreatedAt.Store(42) |
| 249 | if est, hard := a.estimatedPromptTokens(sess.Messages), a.hardInputCeiling(); est >= hard { |
| 250 | t.Fatalf("fixture estimates %d tokens against a %d ceiling; it is not below it", est, hard) |
| 251 | } |
| 252 | |
| 253 | // The failed pressure summary is recorded, not fatal: the turn goes out. |
| 254 | if err := prepareContext(context.Background(), a, CompactionTriggerPressure); err != nil { |
| 255 | t.Fatalf("prepare = %v, want the turn to proceed unfolded", err) |
| 256 | } |
| 257 | if r := a.sess.compactionState.LastReceipt; r == nil || (r.Status != "blocked" && r.Status != "failed") { |
| 258 | t.Fatalf("receipt = %+v, want the failure recorded", r) |
| 259 | } |
| 260 | |
| 261 | // The session keeps growing past the ceiling while the receipt stands. |
| 262 | big := strings.Repeat("word ", 400) |
| 263 | for range 4 { |
| 264 | sess.Add(provider.Message{Role: provider.RoleAssistant, Content: big}) |
| 265 | sess.Add(provider.Message{Role: provider.RoleUser, Content: "continue"}) |
| 266 | } |
| 267 | if est, hard := a.estimatedPromptTokens(sess.Messages), a.hardInputCeiling(); est < hard { |
| 268 | t.Fatalf("grown fixture estimates %d tokens against a %d ceiling; it is not past it", est, hard) |
| 269 | } |
| 270 | |
| 271 | if err := prepareContext(context.Background(), a, CompactionTriggerPressure); err != nil { |
| 272 | t.Fatalf("over-ceiling prepare = %v, want the truncation rescue", err) |
| 273 | } |
| 274 | if degradedFold(a) { |
| 275 | t.Fatal("hard-ceiling failure installed a mechanical digest") |
| 276 | } |
| 277 | if !truncatedRescue(a) { |
| 278 | t.Fatalf("receipt = %+v, want an applied truncation", a.sess.compactionState.LastReceipt) |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | // A ceiling recovery that lands under the fold trigger clears the stuck |
| 283 | // latch: the next pressure round above the trigger must compact again instead |
| 284 | // of coasting back to the physical ceiling. |
| 285 | func TestCeilingRecoveryClearsStuckLatch(t *testing.T) { |
| 286 | sess := foldableSessionOverForce(10) |
| 287 | a := agentOverForce(t, &fakeProvider{reply: "digest"}, sess) |
| 288 | if est, hard := a.estimatedPromptTokens(sess.Messages), a.hardInputCeiling(); est < hard { |
| 289 | t.Fatalf("fixture estimates %d tokens against a %d ceiling; it is not past it", est, hard) |
| 290 | } |
| 291 | a.sess.compaction.stuck = true |
| 292 | |
| 293 | if err := prepareContext(context.Background(), a, CompactionTriggerPressure); err != nil { |
| 294 | t.Fatalf("ceiling recovery = %v, want a fold despite the latch", err) |
| 295 | } |
| 296 | if a.sess.compaction.stuck { |
| 297 | t.Fatal("stale stuck latch survived a recovery that landed under the trigger") |
| 298 | } |
| 299 | version := a.currentProjectionVersion() |
| 300 | if version == 0 { |
| 301 | t.Fatal("recovery installed no projection") |
| 302 | } |
| 303 | |
| 304 | // One big append jumps from under the trigger straight past it, still |
| 305 | // below the ceiling: the pressure round must compact, not coast. |
| 306 | big := strings.Repeat("word ", 400) |
| 307 | for range 20 { |
| 308 | sess.Add(provider.Message{Role: provider.RoleAssistant, Content: big}) |
| 309 | sess.Add(provider.Message{Role: provider.RoleUser, Content: "continue"}) |
| 310 | current := a.contextManager().currentPrepared() |
| 311 | if current.InputTokens >= a.compactTrigger() && current.InputTokens < a.hardInputCeiling() { |
| 312 | break |
| 313 | } |
| 314 | } |
| 315 | if est, fold := a.contextManager().currentPrepared().InputTokens, a.compactTrigger(); est < fold { |
| 316 | t.Fatalf("grown fixture estimates %d tokens, below fold %d", est, fold) |
| 317 | } |
| 318 | if err := prepareContext(context.Background(), a, CompactionTriggerPressure); err != nil { |
| 319 | t.Fatalf("pressure round above the trigger: %v", err) |
| 320 | } |
| 321 | if a.currentProjectionVersion() == version { |
| 322 | t.Fatal("pressure round above the trigger did not compact; the stale latch suppressed it") |
| 323 | } |
| 324 | } |
| 325 | |
| 326 | // Cancellation is the user's decision, not a summarizer failure: it must keep |
| 327 | // its error and leave the projection alone. |
| 328 | func TestCallerCancellationDoesNotDegrade(t *testing.T) { |
| 329 | sess := foldableSessionOverForce(6) |
| 330 | a := agentOverForce(t, &fakeProvider{hang: true}, sess) |
| 331 | |
| 332 | ctx, cancel := context.WithCancel(context.Background()) |
| 333 | cancel() |
| 334 | |
| 335 | if err := prepareContext(ctx, a, CompactionTriggerOverflow); err == nil { |
| 336 | t.Fatal("cancelled prepare reported success") |
| 337 | } |
| 338 | if degradedFold(a) { |
| 339 | t.Error("cancellation installed a degraded fold; it should change nothing") |
| 340 | } |
| 341 | } |
| 342 |