| 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 |