返回 DeepSeek-Reasonix
context_manager_test.go
根目录 / internal / agent / context_manager_test.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "path/filepath"
8 "strings"
9 "sync"
10 "sync/atomic"
11 "testing"
12
13 "reasonix/internal/event"
14 "reasonix/internal/extension"
15 "reasonix/internal/extension/protocol"
16 "reasonix/internal/provider"
17 "reasonix/internal/tool"
18 )
19
20 type failingSummaryProvider struct{ calls int }
21
22 type blockingSummaryProvider struct {
23 calls atomic.Int32
24 started chan struct{}
25 release chan struct{}
26 once sync.Once
27 }
28
29 func (p *blockingSummaryProvider) Name() string { return "blocking-summary" }
30 func (p *blockingSummaryProvider) ContextBudgetPolicy() provider.ContextBudgetPolicy {
31 return provider.ContextBudgetPolicy{WindowMode: provider.ContextWindowIndependent}
32 }
33 func (p *blockingSummaryProvider) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
34 p.calls.Add(1)
35 p.once.Do(func() { close(p.started) })
36 ch := make(chan provider.Chunk, 2)
37 go func() {
38 defer close(ch)
39 select {
40 case <-ctx.Done():
41 ch <- provider.Chunk{Type: provider.ChunkError, Err: ctx.Err()}
42 case <-p.release:
43 ch <- provider.Chunk{Type: provider.ChunkText, Text: "digest"}
44 ch <- provider.Chunk{Type: provider.ChunkDone}
45 }
46 }()
47 return ch, nil
48 }
49
50 func (p *failingSummaryProvider) Name() string { return "failing-summary" }
51
52 func (p *failingSummaryProvider) ContextBudgetPolicy() provider.ContextBudgetPolicy {
53 return provider.ContextBudgetPolicy{WindowMode: provider.ContextWindowIndependent}
54 }
55
56 func (p *failingSummaryProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {
57 p.calls++
58 ch := make(chan provider.Chunk, 1)
59 ch <- provider.Chunk{Type: provider.ChunkError, Err: errors.New("summary unavailable")}
60 close(ch)
61 return ch, nil
62 }
63
64 func TestConcurrentPrepareRunsOneMaintenanceSequence(t *testing.T) {
65 prov := &blockingSummaryProvider{started: make(chan struct{}), release: make(chan struct{})}
66 a := agentOverForce(t, prov, foldableSessionOverForce(6))
67 results := make(chan error, 2)
68 prepare := func() {
69 _, err := a.contextManager().Prepare(context.Background(), ContextPreparePolicy{Trigger: CompactionTriggerPressure})
70 results <- err
71 }
72
73 go prepare()
74 <-prov.started
75 secondEntered := make(chan struct{})
76 go func() {
77 close(secondEntered)
78 prepare()
79 }()
80 <-secondEntered
81 close(prov.release)
82 for range 2 {
83 if err := <-results; err != nil {
84 t.Fatalf("Prepare: %v", err)
85 }
86 }
87 if got := prov.calls.Load(); got != 1 {
88 t.Fatalf("concurrent Prepare issued %d summary calls, want one", got)
89 }
90 if got := a.currentProjectionVersion(); got != 1 {
91 t.Fatalf("projection version = %d, want one committed maintenance sequence", got)
92 }
93 }
94
95 func TestContextManagerPersistsAndRestoresBlockedFailureFingerprint(t *testing.T) {
96 // Above compact_ratio but below the physical hard ceiling: a failed summary
97 // records a generation-scoped blocked receipt and does not reject the request.
98 // Below hard, Prepare returns the uncompacted view rather than ErrCompactionRequired.
99 const window = 10_000
100 messages := []provider.Message{
101 {Role: provider.RoleSystem, Content: "system"},
102 {Role: provider.RoleUser, Content: "task"},
103 {Role: provider.RoleAssistant, Content: strings.Repeat("old work ", 500)},
104 {Role: provider.RoleUser, Content: "current"},
105 {Role: provider.RoleAssistant, Content: "tail"},
106 }
107 path := filepath.Join(t.TempDir(), "session.jsonl")
108 newAgent := func(p *failingSummaryProvider) *Agent {
109 a := New(p, tool.NewRegistry(), &Session{Messages: append([]provider.Message(nil), messages...)}, Options{
110 ContextWindow: window, CompactRatio: 0.85, RecentKeep: 2,
111 WorkspaceID: "workspace", ModelRef: "model",
112 }, event.Discard)
113 a.BindSessionPath(path, true)
114 return a
115 }
116
117 firstProvider := &failingSummaryProvider{}
118 first := newAgent(firstProvider)
119 // fold = 8500; hard = 9744. Observe between them so failure is non-fatal.
120 policy := ContextPreparePolicy{Trigger: CompactionTriggerPressure, ObservedInputTokens: 8600}
121 if _, err := first.contextManager().Prepare(context.Background(), policy); err != nil {
122 t.Fatalf("above-ratio failure should persist blocked state without rejecting this request: %v", err)
123 }
124 if firstProvider.calls != 1 { // single summary attempt; no summarizeOnce second pass
125 t.Fatalf("summary calls = %d, want 1", firstProvider.calls)
126 }
127 if first.sess.compactionState.LastReceipt == nil {
128 t.Fatal("failed summary did not persist a maintenance receipt")
129 }
130 if status := first.sess.compactionState.LastReceipt.Status; status != "blocked" && status != "failed" {
131 t.Fatalf("receipt status = %q, want blocked or failed", status)
132 }
133 if first.sess.compactionState.LastReceipt.BlockedInputHash == "" {
134 t.Fatal("failure receipt missing input hash")
135 }
136 if first.sess.compactionState.BlockedInputHash != "" {
137 t.Fatalf("top-level blocked mirror should not be written: %q", first.sess.compactionState.BlockedInputHash)
138 }
139 if _, err := first.contextManager().Prepare(context.Background(), policy); err != nil {
140 t.Fatal(err)
141 }
142 if firstProvider.calls != 1 {
143 t.Fatalf("same in-memory fingerprint retried summary: calls=%d", firstProvider.calls)
144 }
145
146 resumedProvider := &failingSummaryProvider{}
147 resumed := newAgent(resumedProvider)
148 if resumed.sess.compactionState.LastReceipt == nil {
149 t.Fatal("failure receipt was not restored")
150 }
151 if status := resumed.sess.compactionState.LastReceipt.Status; status != "blocked" && status != "failed" {
152 t.Fatalf("restored receipt status = %q, want blocked or failed", status)
153 }
154 if _, err := resumed.contextManager().Prepare(context.Background(), policy); err != nil {
155 t.Fatal(err)
156 }
157 if resumedProvider.calls != 0 {
158 t.Fatalf("resumed blocked fingerprint retried summary %d times", resumedProvider.calls)
159 }
160 }
161
162 // A failed summary on a new view must refresh the stored receipt hash so the
163 // retry backoff follows the current view; otherwise every round on it pays
164 // for another summary attempt.
165 func TestFailedSummaryReceiptTracksLatestViewHash(t *testing.T) {
166 const window = 10_000
167 sess := &Session{Messages: []provider.Message{
168 {Role: provider.RoleSystem, Content: "system"},
169 {Role: provider.RoleUser, Content: "task"},
170 {Role: provider.RoleAssistant, Content: strings.Repeat("old work ", 500)},
171 {Role: provider.RoleUser, Content: "current"},
172 {Role: provider.RoleAssistant, Content: "tail"},
173 }}
174 prov := &failingSummaryProvider{}
175 a := New(prov, tool.NewRegistry(), sess, Options{
176 ContextWindow: window, CompactRatio: 0.85, RecentKeep: 2,
177 }, event.Discard)
178 policy := ContextPreparePolicy{Trigger: CompactionTriggerPressure, ObservedInputTokens: 8600}
179
180 if _, err := a.contextManager().Prepare(context.Background(), policy); err != nil {
181 t.Fatalf("first failure should pass the turn through: %v", err)
182 }
183 r := a.sess.compactionState.LastReceipt
184 if r == nil || r.BlockedInputHash == "" {
185 t.Fatal("no failure receipt recorded")
186 }
187 firstHash := r.BlockedInputHash
188
189 sess.Add(provider.Message{Role: provider.RoleUser, Content: "more work"})
190 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("extra ", 100)})
191 if _, err := a.contextManager().Prepare(context.Background(), policy); err != nil {
192 t.Fatalf("second failure should pass the turn through: %v", err)
193 }
194 if prov.calls != 2 {
195 t.Fatalf("summary calls = %d, want one per view", prov.calls)
196 }
197 if a.sess.compactionState.LastReceipt.BlockedInputHash == firstHash {
198 t.Fatal("receipt still carries the first view's hash; retries of the new view will never back off")
199 }
200
201 if _, err := a.contextManager().Prepare(context.Background(), policy); err != nil {
202 t.Fatal(err)
203 }
204 if prov.calls != 2 {
205 t.Fatalf("same-view retry paid another summary: calls=%d, want 2", prov.calls)
206 }
207 }
208
209 // A successful summary that still lands above the soft trigger pauses only
210 // that exact provider-visible view. Once new messages change the input hash,
211 // maintenance must be allowed to try the newly foldable region instead of
212 // coasting all the way to the physical ceiling.
213 func TestStuckLatchDoesNotBlockChangedInput(t *testing.T) {
214 const window = 10_000
215 sess := &Session{Messages: []provider.Message{
216 {Role: provider.RoleSystem, Content: "system"},
217 {Role: provider.RoleUser, Content: "task"},
218 {Role: provider.RoleAssistant, Content: strings.Repeat("old work ", 500)},
219 {Role: provider.RoleUser, Content: "current"},
220 {Role: provider.RoleAssistant, Content: "tail"},
221 }}
222 prov := &failingSummaryProvider{}
223 a := New(prov, tool.NewRegistry(), sess, Options{
224 ContextWindow: window, CompactRatio: 0.85, RecentKeep: 2,
225 }, event.Discard)
226 policy := ContextPreparePolicy{Trigger: CompactionTriggerPressure, ObservedInputTokens: 8600}
227
228 oldHash := a.contextMaintenanceInputHash(a.modelVisibleMessages())
229 a.sess.compaction.stuck = true
230 a.sess.compaction.stuckInputHash = oldHash
231 a.sess.compactionState.LastReceipt = &ContextMaintenanceReceipt{
232 Status: "blocked", Action: "summary", BlockedInputHash: oldHash,
233 }
234 if _, err := a.contextManager().Prepare(context.Background(), policy); err != nil {
235 t.Fatal(err)
236 }
237 if prov.calls != 0 {
238 t.Fatalf("same blocked view made %d summary calls, want 0", prov.calls)
239 }
240
241 sess.Add(provider.Message{Role: provider.RoleUser, Content: "new turn"})
242 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("new foldable work ", 100)})
243 if _, err := a.contextManager().Prepare(context.Background(), policy); err != nil {
244 t.Fatalf("changed input should be allowed one maintenance attempt: %v", err)
245 }
246 if prov.calls != 1 {
247 t.Fatalf("changed input made %d summary calls, want 1", prov.calls)
248 }
249 }
250
251 func TestFailedSummaryReceiptBacksOffChangedViewsWithinActiveTurn(t *testing.T) {
252 const window = 10_000
253 sess := &Session{Messages: []provider.Message{
254 {Role: provider.RoleSystem, Content: "system"},
255 {Role: provider.RoleUser, Content: "task"},
256 {Role: provider.RoleAssistant, Content: strings.Repeat("old work ", 500)},
257 {Role: provider.RoleUser, Content: "current"},
258 {Role: provider.RoleAssistant, Content: "tail"},
259 }}
260 prov := &failingSummaryProvider{}
261 a := New(prov, tool.NewRegistry(), sess, Options{
262 ContextWindow: window, CompactRatio: 0.85, RecentKeep: 2,
263 }, event.Discard)
264 policy := ContextPreparePolicy{Trigger: CompactionTriggerPressure, ObservedInputTokens: 8600}
265 a.activeTurnCreatedAt.Store(11)
266
267 if _, err := a.contextManager().Prepare(context.Background(), policy); err != nil {
268 t.Fatal(err)
269 }
270 sess.Add(provider.Message{Role: provider.RoleTool, Content: strings.Repeat("new output ", 100)})
271 if _, err := a.contextManager().Prepare(context.Background(), policy); err != nil {
272 t.Fatal(err)
273 }
274 if prov.calls != 1 {
275 t.Fatalf("same-turn changed view made %d summary calls, want 1", prov.calls)
276 }
277
278 a.activeTurnCreatedAt.Store(12)
279 if _, err := a.contextManager().Prepare(context.Background(), policy); err != nil {
280 t.Fatal(err)
281 }
282 if prov.calls != 2 {
283 t.Fatalf("later turn made %d summary calls, want one fresh retry", prov.calls)
284 }
285 }
286
287 // TestPrepareThresholdSkipsExtensionInterceptors locks the overflow-only
288 // contract: automatic compact_ratio uses the pre-interceptor request shape
289 // (messages + tools + role projection). context.prepare / provider.request
290 // interceptors run only on the real sampling path so side-effecting plugins
291 // are not double-invoked for threshold decisions.
292 func TestPrepareThresholdSkipsExtensionInterceptors(t *testing.T) {
293 var prepareHits, providerHits atomic.Int32
294 client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
295 switch ev {
296 case protocol.EventContextPrepare:
297 prepareHits.Add(1)
298 case protocol.EventProviderRequest:
299 providerHits.Add(1)
300 }
301 return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
302 }}
303 d := newExtDispatcher(client, true, nil, extension.PointContextPrepare, extension.PointProviderRequest)
304 sess := &Session{Messages: []provider.Message{
305 {Role: provider.RoleSystem, Content: "system"},
306 {Role: provider.RoleUser, Content: "task"},
307 {Role: provider.RoleAssistant, Content: "ok"},
308 }}
309 a := New(&fakeProvider{reply: "unused"}, tool.NewRegistry(), sess, Options{
310 ContextWindow: 50_000, CompactRatio: 0.85, RecentKeep: 2,
311 Extensions: d, WorkspaceID: "ws", ModelRef: "m",
312 }, event.Discard)
313
314 // Below fold: Prepare sizes the view and must not touch interceptors.
315 if _, err := a.contextManager().Prepare(context.Background(), ContextPreparePolicy{
316 Trigger: CompactionTriggerPressure, ObservedInputTokens: 100,
317 }); err != nil {
318 t.Fatalf("Prepare: %v", err)
319 }
320 if prepareHits.Load() != 0 || providerHits.Load() != 0 {
321 t.Fatalf("threshold Prepare invoked interceptors: prepare=%d provider=%d",
322 prepareHits.Load(), providerHits.Load())
323 }
324
325 // Real sampling assembly still runs both interceptor points once.
326 if _, err := a.buildSamplingRequest(context.Background(), CompactionTriggerPressure); err != nil {
327 t.Fatalf("buildSamplingRequest: %v", err)
328 }
329 if prepareHits.Load() != 1 || providerHits.Load() != 1 {
330 t.Fatalf("sampling path interceptors: prepare=%d provider=%d, want 1 each",
331 prepareHits.Load(), providerHits.Load())
332 }
333 }
334
335 func TestStrictAlternatingRolesStillConvergesBeforeSampling(t *testing.T) {
336 sess := &Session{Messages: []provider.Message{
337 {Role: provider.RoleSystem, Content: "system"},
338 {Role: provider.RoleUser, Content: "old request"},
339 {Role: provider.RoleAssistant, Content: strings.Repeat("old work ", 4000)},
340 {Role: provider.RoleUser, Content: "recent request"},
341 {Role: provider.RoleAssistant, Content: "recent response"},
342 }}
343 a := New(&fakeProvider{reply: "old work summarized"}, tool.NewRegistry(), sess, Options{
344 ContextWindow: 10_000, RecentKeep: 2, StrictAlternatingRoles: true,
345 }, event.Discard)
346
347 prepared, err := a.prepareSamplingRequest(context.Background())
348 if err != nil {
349 t.Fatalf("prepareSamplingRequest: %v", err)
350 }
351 if got := a.currentProjectionVersion(); got != 1 {
352 t.Fatalf("projection version = %d, want pressure fold", got)
353 }
354 if len(prepared.req.Messages) >= len(sess.Snapshot()) {
355 t.Fatalf("strict request did not converge: %+v", prepared.req.Messages)
356 }
357 for i := 1; i < len(prepared.req.Messages); i++ {
358 if prepared.req.Messages[i-1].Role == prepared.req.Messages[i].Role {
359 t.Fatalf("strict request has adjacent %s roles: %+v", prepared.req.Messages[i].Role, prepared.req.Messages)
360 }
361 }
362 }
363
363 lines GO