返回 DeepSeek-Reasonix
session_extract_test.go
根目录 / internal / agent / session_extract_test.go
1 package agent
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "strings"
8 "sync"
9 "testing"
10
11 "reasonix/internal/event"
12 "reasonix/internal/provider"
13 "reasonix/internal/tool"
14 )
15
16 func extractTestMsg(size int, tag string) provider.Message {
17 pad := max(0, size-len(tag))
18 return provider.Message{Role: provider.RoleUser, Content: tag + strings.Repeat("x", pad)}
19 }
20
21 // Small transcripts stay in one chunk: the fast path must cover every message.
22 func TestSplitExtractChunksSingleChunk(t *testing.T) {
23 msgs := []provider.Message{
24 extractTestMsg(100, "a"),
25 extractTestMsg(100, "b"),
26 extractTestMsg(100, "c"),
27 }
28 chunks := splitExtractChunks(msgs, extractChunkOverlapBytes, provider.SharedWindowInputPolicy{})
29 if len(chunks) != 1 {
30 t.Fatalf("chunks = %d, want 1", len(chunks))
31 }
32 if len(chunks[0]) != 3 {
33 t.Fatalf("single chunk holds %d messages, want 3", len(chunks[0]))
34 }
35 }
36
37 // Large transcripts split newest-tail-first along the exponential size table,
38 // never split a message, and share overlap messages at adjacent boundaries.
39 func TestSplitExtractChunksExponential(t *testing.T) {
40 // 40 messages of ~48KiB each ≈ 1.9MiB total — enough for five chunks.
41 msgs := make([]provider.Message, 40)
42 for i := range msgs {
43 msgs[i] = extractTestMsg(48<<10, fmt.Sprintf("<%06d>", i))
44 }
45 chunks := splitExtractChunks(msgs, extractChunkOverlapBytes, provider.SharedWindowInputPolicy{})
46 if len(chunks) < 4 {
47 t.Fatalf("chunks = %d, want >= 4 for a 1.9MiB transcript", len(chunks))
48 }
49 // Every message appears in exactly one chunk, except the overlap region
50 // which is duplicated across adjacent chunks; walking oldest → newest the
51 // chunks must cover the transcript in order with no gaps.
52 covered := 0
53 for i, chunk := range chunks {
54 if len(chunk) == 0 {
55 t.Fatalf("chunk %d is empty", i)
56 }
57 // Find the chunk's first message in the transcript to check ordering.
58 first := indexOfMessage(msgs, chunk[0])
59 if first < 0 {
60 t.Fatalf("chunk %d first message not found in transcript", i)
61 }
62 if first > covered+1 && i > 0 {
63 t.Fatalf("gap between chunk %d and its predecessor: first=%d covered=%d", i, first, covered)
64 }
65 if i > 0 {
66 // Overlap: the newer chunk (i-1) shares its head with this chunk's
67 // tail — the same transcript message must appear in both.
68 prevLast := indexOfMessage(msgs, chunks[i-1][len(chunks[i-1])-1])
69 if prevLast < first {
70 t.Fatalf("no overlap between chunk %d and %d", i-1, i)
71 }
72 }
73 covered = indexOfMessage(msgs, chunk[len(chunk)-1])
74 }
75 if covered != len(msgs)-1 {
76 t.Fatalf("chunks end at message %d, want %d (transcript tail must be in the newest chunk)", covered, len(msgs)-1)
77 }
78 // Newest chunk holds the last message; oldest holds the first.
79 if indexOfMessage(msgs, chunks[len(chunks)-1][len(chunks[len(chunks)-1])-1]) != len(msgs)-1 {
80 t.Fatalf("newest chunk does not end at the transcript tail")
81 }
82 if indexOfMessage(msgs, chunks[0][0]) != 0 {
83 t.Fatalf("oldest chunk does not start at the transcript head")
84 }
85 }
86
87 // Boundaries never split a message: every chunk is a contiguous slice of the
88 // original transcript.
89 func TestSplitExtractChunksContiguousSlices(t *testing.T) {
90 msgs := make([]provider.Message, 24)
91 for i := range msgs {
92 msgs[i] = extractTestMsg(100<<10, fmt.Sprintf("<%06d>", i))
93 }
94 chunks := splitExtractChunks(msgs, extractChunkOverlapBytes, provider.SharedWindowInputPolicy{})
95 for i, chunk := range chunks {
96 if len(chunk) == 0 {
97 t.Fatalf("chunk %d empty", i)
98 }
99 start := indexOfMessage(msgs, chunk[0])
100 if start < 0 || start+len(chunk) > len(msgs) {
101 t.Fatalf("chunk %d has invalid source span [%d:%d]", i, start, start+len(chunk))
102 }
103 for j, msg := range chunk {
104 want := msgs[start+j]
105 if msg.Role != want.Role || msg.Content != want.Content {
106 t.Fatalf("chunk %d message %d is not source message %d", i, j, start+j)
107 }
108 }
109 }
110 }
111
112 func indexOfMessage(msgs []provider.Message, target provider.Message) int {
113 for i := range msgs {
114 if msgs[i].Content == target.Content && msgs[i].Role == target.Role {
115 return i
116 }
117 }
118 return -1
119 }
120
121 // extractStubProvider fails the first `failFirst` summarize requests with the
122 // output-truncation signal (FinishReason=length, the #9082 follow-up failure
123 // mode on very large sessions), then replies normally. streamErr, when set,
124 // is a non-retriable transport failure surfaced on every call. Every request's
125 // message count is recorded so merge-grouping tests can assert the merge
126 // request never carried the whole fragment set.
127 type extractStubProvider struct {
128 mu sync.Mutex
129 calls int
130 failFirst int
131 streamErr error
132 reply string
133 msgLens []int
134 reqEsts []int
135 requests []provider.Request
136 }
137
138 func (p *extractStubProvider) Name() string { return "extract-stub" }
139
140 func (p *extractStubProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
141 p.mu.Lock()
142 p.calls++
143 p.msgLens = append(p.msgLens, len(req.Messages))
144 p.reqEsts = append(p.reqEsts, estimateMessagesTokens(req.Messages))
145 requestCopy := req
146 requestCopy.Messages = append([]provider.Message(nil), req.Messages...)
147 p.requests = append(p.requests, requestCopy)
148 n := p.calls
149 p.mu.Unlock()
150 ch := make(chan provider.Chunk, 3)
151 if p.streamErr != nil {
152 ch <- provider.Chunk{Type: provider.ChunkError, Err: p.streamErr}
153 close(ch)
154 return ch, nil
155 }
156 if n <= p.failFirst {
157 ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: &provider.Usage{
158 PromptTokens: 10, TotalTokens: 10, CacheHitTokens: 3, CacheMissTokens: 7,
159 FinishReason: "length", RequestCount: 1,
160 }}
161 ch <- provider.Chunk{Type: provider.ChunkDone}
162 close(ch)
163 return ch, nil
164 }
165 ch <- provider.Chunk{Type: provider.ChunkText, Text: p.reply}
166 ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: &provider.Usage{
167 PromptTokens: 10, CompletionTokens: 2, TotalTokens: 12,
168 CacheHitTokens: 3, CacheMissTokens: 7, RequestCount: 1,
169 }}
170 ch <- provider.Chunk{Type: provider.ChunkDone}
171 close(ch)
172 return ch, nil
173 }
174
175 func requestContains(req provider.Request, marker string) bool {
176 for _, msg := range req.Messages {
177 if strings.Contains(msg.Content, marker) {
178 return true
179 }
180 }
181 return false
182 }
183
184 func assertActualToolResultSurvives(t *testing.T, msgs []provider.Message, callID, marker string) {
185 t.Helper()
186 wire := provider.SanitizeToolPairing(msgs)
187 for i, msg := range wire {
188 if msg.Role != provider.RoleAssistant {
189 continue
190 }
191 for _, call := range msg.ToolCalls {
192 if call.ID != callID {
193 continue
194 }
195 if i+1 >= len(wire) || wire[i+1].Role != provider.RoleTool || wire[i+1].ToolCallID != callID || !strings.Contains(wire[i+1].Content, marker) {
196 t.Fatalf("tool result %q did not survive sanitization: %+v", marker, wire)
197 }
198 return
199 }
200 }
201 t.Fatalf("tool call %q missing after sanitization: %+v", callID, wire)
202 }
203
204 func extractStubSession() *Session {
205 sess := NewSession("sys")
206 sess.Add(provider.Message{Role: provider.RoleUser, Content: "task one"})
207 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "answer one"})
208 sess.Add(provider.Message{Role: provider.RoleUser, Content: "task two"})
209 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "answer two"})
210 return sess
211 }
212
213 func TestChunkedFoldSummarySplitsOnOutputTruncation(t *testing.T) {
214 prov := &extractStubProvider{failFirst: 1, reply: "digest"}
215 a := New(prov, tool.NewRegistry(), extractStubSession(), Options{}, event.Discard)
216 res, err := a.chunkedFoldSummary(context.Background(), a.Session().Snapshot(), compactionInstruction, nil)
217 if err != nil {
218 t.Fatalf("chunkedFoldSummary: %v", err)
219 }
220 if strings.TrimSpace(res.Text) == "" {
221 t.Fatal("empty summary after split recovery")
222 }
223 // 1 failing root + 2 half fragments + 1 merge = 4 calls.
224 if prov.calls != 4 {
225 t.Fatalf("provider calls = %d, want 4 (fail, two halves, merge)", prov.calls)
226 }
227 }
228
229 func TestSplitExtractChunksKeepsToolTurnAtomic(t *testing.T) {
230 const marker = "ACTUAL-TOOL-RESULT"
231 msgs := []provider.Message{
232 extractTestMsg(200<<10, "old"),
233 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "c1", Name: "read_file", Arguments: `{}`}}},
234 {Role: provider.RoleTool, ToolCallID: "c1", Name: "read_file", Content: marker + strings.Repeat("r", 80<<10)},
235 {Role: provider.RoleAssistant, Content: "done"},
236 }
237 chunks := splitExtractChunks(msgs, extractChunkOverlapBytes, provider.SharedWindowInputPolicy{})
238 if len(chunks) < 2 {
239 t.Fatalf("chunks = %d, want a boundary around the oversized tool turn", len(chunks))
240 }
241 seen := 0
242 for _, chunk := range chunks {
243 containsMarker := false
244 for _, msg := range chunk {
245 containsMarker = containsMarker || strings.Contains(msg.Content, marker)
246 }
247 if containsMarker {
248 seen++
249 assertActualToolResultSurvives(t, chunk, "c1", marker)
250 }
251 }
252 if seen == 0 {
253 t.Fatal("no chunk retained the actual tool result")
254 }
255 }
256
257 func TestSplitExtractFragmentKeepsToolTurnAtomic(t *testing.T) {
258 const marker = "ACTUAL-SPLIT-RESULT"
259 chunk := []provider.Message{
260 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "c1", Name: "bash", Arguments: `{}`}}},
261 {Role: provider.RoleTool, ToolCallID: "c1", Name: "bash", Content: marker},
262 {Role: provider.RoleUser, Content: "next"},
263 }
264 left, right, ok := splitExtractFragment(chunk)
265 if !ok || len(left) != 2 || len(right) != 1 {
266 t.Fatalf("split = (%d, %d, %v), want (2, 1, true)", len(left), len(right), ok)
267 }
268 assertActualToolResultSurvives(t, left, "c1", marker)
269 for _, msg := range right {
270 if msg.Role == provider.RoleTool {
271 t.Fatalf("right half contains an orphan tool result: %+v", right)
272 }
273 }
274 }
275
276 func TestChunkedFoldSummaryDoesNotRetryTransportErrors(t *testing.T) {
277 prov := &extractStubProvider{streamErr: errors.New("provider down")}
278 a := New(prov, tool.NewRegistry(), extractStubSession(), Options{}, event.Discard)
279 if _, err := a.chunkedFoldSummary(context.Background(), a.Session().Snapshot(), compactionInstruction, nil); err == nil {
280 t.Fatal("expected the transport error to surface")
281 }
282 if prov.calls != 1 {
283 t.Fatalf("provider calls = %d, want 1 (no split retry on transport errors)", prov.calls)
284 }
285 }
286
287 func TestChunkedFoldSummarySplitsDeepOnRepeatedTruncation(t *testing.T) {
288 prov := &extractStubProvider{failFirst: 2, reply: "digest"}
289 a := New(prov, tool.NewRegistry(), extractStubSession(), Options{}, event.Discard)
290 res, err := a.chunkedFoldSummary(context.Background(), a.Session().Snapshot(), compactionInstruction, nil)
291 if err != nil {
292 t.Fatalf("chunkedFoldSummary: %v", err)
293 }
294 if strings.TrimSpace(res.Text) == "" {
295 t.Fatal("empty summary after deep split recovery")
296 }
297 if prov.calls < 6 {
298 t.Fatalf("provider calls = %d, want a deep split (>=6)", prov.calls)
299 }
300 }
301
302 func TestChunkedFoldSummarySingleMessageFragmentCannotSplit(t *testing.T) {
303 prov := &extractStubProvider{failFirst: 99, reply: "digest"}
304 sess := NewSession("sys")
305 sess.Add(provider.Message{Role: provider.RoleUser, Content: "only"})
306 a := New(prov, tool.NewRegistry(), sess, Options{}, event.Discard)
307 if _, err := a.chunkedFoldSummary(context.Background(), a.Session().Snapshot(), compactionInstruction, nil); err == nil {
308 t.Fatal("expected failure when every split level truncates and no split remains")
309 }
310 }
311
312 func TestChunkedFoldSummarySingleAtomicToolTurnCannotSplit(t *testing.T) {
313 prov := &extractStubProvider{failFirst: 99, reply: "digest"}
314 a := New(prov, tool.NewRegistry(), NewSession("sys"), Options{}, event.Discard)
315 chunk := []provider.Message{
316 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "c1", Name: "bash", Arguments: `{}`}}},
317 {Role: provider.RoleTool, ToolCallID: "c1", Name: "bash", Content: "actual"},
318 }
319 run := newChunkedSummaryRun(a)
320 if _, err := a.extractFragmentResilient(context.Background(), chunk, extractFragmentInstruction(1, 1, ""), extractMergeInstruction, func(bool) {}, run, 0); err == nil {
321 t.Fatal("expected failure rather than splitting one atomic tool turn")
322 }
323 if prov.calls != 1 {
324 t.Fatalf("provider calls = %d, want 1 for an indivisible tool turn", prov.calls)
325 }
326 }
327
328 func TestMergeFragmentsGroupsWhenOverBudget(t *testing.T) {
329 // A 2k window shrinks mergeInputBudget to ~616 tokens. Two fragment
330 // briefings overflow that planning threshold but still fit the summary
331 // request itself, so the tree reducer must merge the pair in one request.
332 prov := &extractStubProvider{reply: "digest"}
333 a := New(prov, tool.NewRegistry(), extractStubSession(), Options{ContextWindow: 2000}, event.Discard)
334 parts := []string{strings.Repeat("old ", 320), strings.Repeat("new ", 320)}
335 if estimateMessagesTokens(mergeDigestMessages(parts)) <= a.mergeInputBudget() {
336 t.Fatal("test setup did not exceed the merge planning budget")
337 }
338 merged, err := a.mergeFragments(context.Background(), parts)
339 if err != nil {
340 t.Fatalf("mergeFragments: %v", err)
341 }
342 if merged != "digest" {
343 t.Fatalf("merged = %q, want digest", merged)
344 }
345 if prov.calls != 1 {
346 t.Fatalf("provider calls = %d, want 1 grouped merge", prov.calls)
347 }
348 }
349
350 func TestChunkedFoldSummarySkipsGroupingWithinBudget(t *testing.T) {
351 // Unknown window (budget = MaxInt): the merge stays a single request.
352 prov := &extractStubProvider{reply: "digest"}
353 a := New(prov, tool.NewRegistry(), extractStubSession(), Options{}, event.Discard)
354 if _, err := a.chunkedFoldSummary(context.Background(), a.Session().Snapshot(), compactionInstruction, nil); err != nil {
355 t.Fatalf("chunkedFoldSummary: %v", err)
356 }
357 // Single chunk fast path: 1 fragment request, no merge needed.
358 if prov.calls != 1 {
359 t.Fatalf("provider calls = %d, want 1", prov.calls)
360 }
361 }
362
363 func TestMergeFragmentsRetriesFinalUnknownWindowMerge(t *testing.T) {
364 // An unknown window skips proactive grouping. If the final whole-set
365 // request still truncates, mergeGroup must split and tree-reduce instead
366 // of discarding briefings that are already in hand.
367 prov := &extractStubProvider{failFirst: 1, reply: "digest"}
368 a := New(prov, tool.NewRegistry(), extractStubSession(), Options{}, event.Discard)
369 merged, err := a.mergeFragments(context.Background(), []string{"one", "two", "three", "four"})
370 if err != nil {
371 t.Fatalf("mergeFragments: %v", err)
372 }
373 if merged != "digest" {
374 t.Fatalf("merged = %q, want digest", merged)
375 }
376 // 1 failing whole-set request + 2 successful halves + 1 final merge.
377 if prov.calls != 4 {
378 t.Fatalf("provider calls = %d, want 4", prov.calls)
379 }
380 }
381
382 type noProgressMergeProvider struct {
383 mu sync.Mutex
384 calls int
385 }
386
387 func (p *noProgressMergeProvider) Name() string { return "no-progress-merge" }
388
389 func (p *noProgressMergeProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
390 p.mu.Lock()
391 p.calls++
392 p.mu.Unlock()
393 parts := make([]string, 0, 2)
394 for _, msg := range req.Messages {
395 if !strings.HasPrefix(msg.Content, "<fragment index=") {
396 continue
397 }
398 if start := strings.IndexByte(msg.Content, '\n'); start >= 0 {
399 if end := strings.LastIndex(msg.Content, "\n</fragment>"); end > start {
400 parts = append(parts, msg.Content[start+1:end])
401 }
402 }
403 }
404 ch := make(chan provider.Chunk, 3)
405 if len(parts) >= 2 {
406 ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: 10, TotalTokens: 10, FinishReason: "length", RequestCount: 1}}
407 } else if len(parts) == 1 {
408 ch <- provider.Chunk{Type: provider.ChunkText, Text: parts[0]}
409 ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: 10, CompletionTokens: 1, TotalTokens: 11, RequestCount: 1}}
410 }
411 ch <- provider.Chunk{Type: provider.ChunkDone}
412 close(ch)
413 return ch, nil
414 }
415
416 func TestMergeFragmentsStopsWhenRecoveryMakesNoProgress(t *testing.T) {
417 prov := &noProgressMergeProvider{}
418 a := New(prov, tool.NewRegistry(), extractStubSession(), Options{}, event.Discard)
419 _, err := a.mergeFragments(context.Background(), []string{"one", "two"})
420 if err == nil || !strings.Contains(err.Error(), "made no progress") {
421 t.Fatalf("merge error = %v, want explicit no-progress failure", err)
422 }
423 if prov.calls != 3 {
424 t.Fatalf("provider calls = %d, want 3 bounded calls (pair + two singletons)", prov.calls)
425 }
426 }
427
428 func TestChunkedSummaryRunEnforcesCallBudget(t *testing.T) {
429 prov := &extractStubProvider{reply: "digest"}
430 a := New(prov, tool.NewRegistry(), extractStubSession(), Options{}, event.Discard)
431 run := newChunkedSummaryRun(a)
432 for i := range maxChunkedSummaryCalls {
433 if _, err := run.summarize(context.Background(), []provider.Message{{Role: provider.RoleUser, Content: "x"}}, extractMergeInstruction, 0); err != nil {
434 t.Fatalf("call %d: %v", i+1, err)
435 }
436 }
437 if _, err := run.summarize(context.Background(), []provider.Message{{Role: provider.RoleUser, Content: "x"}}, extractMergeInstruction, 0); err == nil || !strings.Contains(err.Error(), "call budget exhausted") {
438 t.Fatalf("budget error = %v", err)
439 }
440 if prov.calls != maxChunkedSummaryCalls {
441 t.Fatalf("provider calls = %d, want cap %d", prov.calls, maxChunkedSummaryCalls)
442 }
443 }
444
445 func TestChunkedFallbackPreservesFocusAndAggregatesTelemetry(t *testing.T) {
446 const focus = "KEEP-FOCUS-MARKER-9082"
447 prov := &extractStubProvider{failFirst: 2, reply: "digest"}
448 a := New(prov, tool.NewRegistry(), extractStubSession(), Options{}, event.Discard)
449 fold := a.Session().Snapshot()
450 res, tele, err := a.foldSummaryWithChunkedFallback(context.Background(), CompactionTriggerManual, fold, focus, 321, SummaryInputCachePrefix)
451 if err != nil {
452 t.Fatalf("foldSummaryWithChunkedFallback: %v", err)
453 }
454 if res.InputMode != SummaryInputChunked || tele.SummaryInputMode != SummaryInputChunked {
455 t.Fatalf("input modes = (%q, %q), want %q", res.InputMode, tele.SummaryInputMode, SummaryInputChunked)
456 }
457 if tele.FoldTokens <= 0 {
458 t.Fatalf("fold tokens = %d, want original fold estimate", tele.FoldTokens)
459 }
460 if tele.Spans != prov.calls || tele.RequestCount != prov.calls {
461 t.Fatalf("spans/requests/calls = %d/%d/%d, want exact aggregate", tele.Spans, tele.RequestCount, prov.calls)
462 }
463 if tele.InputTokens != prov.calls*10 || tele.CacheHitTokens != prov.calls*3 || tele.CacheMissTokens != prov.calls*7 {
464 t.Fatalf("aggregated usage = %+v, calls = %d", tele, prov.calls)
465 }
466 if len(prov.requests) < 2 {
467 t.Fatalf("requests = %d, want initial call plus fallback", len(prov.requests))
468 }
469 for i, req := range prov.requests[1:] {
470 if !requestContains(req, focus) {
471 t.Fatalf("fallback request %d discarded focus marker", i+1)
472 }
473 }
474 }
475
476 func TestChunkedFoldSummaryIgnoresLocalRawContentForChunking(t *testing.T) {
477 prov := &extractStubProvider{reply: "digest"}
478 sess := NewSession("sys")
479 sess.Add(provider.Message{Role: provider.RoleUser, Content: "visible-one", RawContent: strings.Repeat("r", 1<<20)})
480 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "visible-two", RawContent: strings.Repeat("p", 1<<20)})
481 a := New(prov, tool.NewRegistry(), sess, Options{}, event.Discard)
482 if _, err := a.chunkedFoldSummary(context.Background(), a.Session().Snapshot(), "", nil); err != nil {
483 t.Fatalf("chunkedFoldSummary: %v", err)
484 }
485 if prov.calls != 1 {
486 t.Fatalf("provider calls = %d, want one provider-visible fragment", prov.calls)
487 }
488 for _, req := range prov.requests {
489 for _, msg := range req.Messages {
490 if msg.RawContent != "" || msg.ProviderContent != "" {
491 t.Fatalf("local-only content reached fallback request: %+v", msg)
492 }
493 }
494 }
495 }
496
497 func TestMergeDigestMessagesUsesDecimalFragmentIndexes(t *testing.T) {
498 msgs := mergeDigestMessages([]string{"oldest", "newest"})
499 if len(msgs) != 3 {
500 t.Fatalf("messages = %d, want 3", len(msgs))
501 }
502 for i, msg := range msgs[1:] {
503 want := fmt.Sprintf("<fragment index=%d>", i+1)
504 if !strings.Contains(msg.Content, want) {
505 t.Fatalf("fragment %d content = %q, want %q", i+1, msg.Content, want)
506 }
507 }
508 }
509
510 func TestCompactFallsBackToChunkedSummaryOnTruncation(t *testing.T) {
511 // The single-request summary hits the output limit; compaction must fall
512 // back to the chunked extract strategy and install the projection in the
513 // same session (one /compact covers over-length sessions, #9082 follow-up).
514 prov := &extractStubProvider{failFirst: 1, reply: "digest"}
515 sess := NewSession("sys")
516 for range 12 {
517 sess.Add(provider.Message{Role: provider.RoleUser, Content: strings.Repeat("u", 6000)})
518 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("a", 6000)})
519 }
520 a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: 100_000, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
521 if err := a.CompactNow(context.Background(), ""); err != nil {
522 t.Fatalf("CompactNow: %v", err)
523 }
524 if len(a.sess.compactionState.Projection.Messages) == 0 {
525 t.Fatal("projection not installed after the chunked fallback")
526 }
527 if prov.calls < 4 {
528 t.Fatalf("provider calls = %d, want the failed single request plus chunks and merge (>=4)", prov.calls)
529 }
530 }
531
531 lines GO