返回 DeepSeek-Reasonix
compact_summary_failure_test.go
根目录 / internal / agent / compact_summary_failure_test.go
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
342 lines GO