fix(core): preserve provider prompt prefixes
- commit
- 1a6392e94847c3e95db12d29c8799047b354c23c
- author
- brice · · 2026-08-18 13:17 UTC
- parent
- aa7307c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +49 -81
M packages/core/context_budget.go +13 -31
| @@ -9,48 +9,30 @@ import ( | |||
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | 11 | const ( | |
| 12 | - | maxToolResultTextBytes = 32 * 1024 | |
| 13 | - | maxToolResultTotalTextBytes = 128 * 1024 | |
| 14 | - | toolResultOmissionMarker = "[tool result content omitted]" | |
| 12 | + | maxToolResultTextBytes = 32 * 1024 | |
| 13 | + | toolResultOmissionMarker = "[tool result content omitted]" | |
| 15 | 14 | ) | |
| 16 | 15 | ||
| 17 | 16 | // projectToolResultMessages returns a copied provider-input view of msgs. | |
| 18 | - | // Text inside each tool result is bounded independently and then against the | |
| 19 | - | // total budget, with newer results receiving the remaining budget first. | |
| 20 | - | // Tool-result blocks and all non-text content remain in the projection so | |
| 21 | - | // tool-call/result pairing stays valid. | |
| 17 | + | // Each tool result is bounded independently. Its projection must depend only | |
| 18 | + | // on that result: retroactively shrinking older results when a new one arrives | |
| 19 | + | // mutates the prompt prefix and defeats provider prompt caching. Compaction | |
| 20 | + | // owns the aggregate transcript budget. | |
| 22 | 21 | func projectToolResultMessages(msgs []provider.Message) []provider.Message { | |
| 23 | 22 | projected := copyToolResultMessages(msgs) | |
| 24 | - | remaining := maxToolResultTotalTextBytes | |
| 25 | 23 | ||
| 26 | - | for i := len(projected) - 1; i >= 0; i-- { | |
| 27 | - | for j := len(projected[i].Content) - 1; j >= 0; j-- { | |
| 28 | - | result, ok := projected[i].Content[j].(provider.ToolResultBlock) | |
| 24 | + | for i := range projected { | |
| 25 | + | for j, content := range projected[i].Content { | |
| 26 | + | result, ok := content.(provider.ToolResultBlock) | |
| 29 | 27 | if !ok { | |
| 30 | 28 | continue | |
| 31 | 29 | } | |
| 32 | 30 | ||
| 33 | - | timingText := "" | |
| 34 | - | if result.Timing != nil { | |
| 35 | - | candidate := formatToolTiming(result.Timing) | |
| 36 | - | if len(candidate) <= remaining { | |
| 37 | - | timingText = candidate | |
| 38 | - | remaining -= len(candidate) | |
| 39 | - | } | |
| 40 | - | } | |
| 41 | - | ||
| 42 | - | textBytes := toolResultTextBytes(result.Content) | |
| 43 | - | if textBytes > 0 { | |
| 44 | - | limit := maxToolResultTextBytes | |
| 45 | - | if remaining < limit { | |
| 46 | - | limit = remaining | |
| 47 | - | } | |
| 48 | - | var retained int | |
| 49 | - | result.Content, retained = projectToolResultContent(result.Content, limit) | |
| 50 | - | remaining -= retained | |
| 31 | + | if toolResultTextBytes(result.Content) > 0 { | |
| 32 | + | result.Content, _ = projectToolResultContent(result.Content, maxToolResultTextBytes) | |
| 51 | 33 | } | |
| 52 | - | if timingText != "" { | |
| 53 | - | result.Content = append(result.Content, provider.TextBlock{Text: timingText}) | |
| 34 | + | if result.Timing != nil { | |
| 35 | + | result.Content = append(result.Content, provider.TextBlock{Text: formatToolTiming(result.Timing)}) | |
| 54 | 36 | } | |
| 55 | 37 | projected[i].Content[j] = result | |
| 56 | 38 | } | |
| @@ -9,48 +9,30 @@ import ( | |||||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | const ( | 11 | const ( | ||
| 12 | - | maxToolResultTextBytes = 32 * 1024 | 12 | + | maxToolResultTextBytes = 32 * 1024 |
| 13 | - | maxToolResultTotalTextBytes = 128 * 1024 | 13 | + | toolResultOmissionMarker = "[tool result content omitted]" |
| 14 | - | toolResultOmissionMarker = "[tool result content omitted]" | |||
| 15 | ) | 14 | ) | ||
| 16 | 15 | ||||
| 17 | // projectToolResultMessages returns a copied provider-input view of msgs. | 16 | // projectToolResultMessages returns a copied provider-input view of msgs. | ||
| 18 | - | // Text inside each tool result is bounded independently and then against the | 17 | + | // Each tool result is bounded independently. Its projection must depend only |
| 19 | - | // total budget, with newer results receiving the remaining budget first. | 18 | + | // on that result: retroactively shrinking older results when a new one arrives |
| 20 | - | // Tool-result blocks and all non-text content remain in the projection so | 19 | + | // mutates the prompt prefix and defeats provider prompt caching. Compaction |
| 21 | - | // tool-call/result pairing stays valid. | 20 | + | // owns the aggregate transcript budget. |
| 22 | func projectToolResultMessages(msgs []provider.Message) []provider.Message { | 21 | func projectToolResultMessages(msgs []provider.Message) []provider.Message { | ||
| 23 | projected := copyToolResultMessages(msgs) | 22 | projected := copyToolResultMessages(msgs) | ||
| 24 | - | remaining := maxToolResultTotalTextBytes | |||
| 25 | 23 | ||||
| 26 | - | for i := len(projected) - 1; i >= 0; i-- { | 24 | + | for i := range projected { |
| 27 | - | for j := len(projected[i].Content) - 1; j >= 0; j-- { | 25 | + | for j, content := range projected[i].Content { |
| 28 | - | result, ok := projected[i].Content[j].(provider.ToolResultBlock) | 26 | + | result, ok := content.(provider.ToolResultBlock) |
| 29 | if !ok { | 27 | if !ok { | ||
| 30 | continue | 28 | continue | ||
| 31 | } | 29 | } | ||
| 32 | 30 | ||||
| 33 | - | timingText := "" | 31 | + | if toolResultTextBytes(result.Content) > 0 { |
| 34 | - | if result.Timing != nil { | 32 | + | result.Content, _ = projectToolResultContent(result.Content, maxToolResultTextBytes) |
| 35 | - | candidate := formatToolTiming(result.Timing) | |||
| 36 | - | if len(candidate) <= remaining { | |||
| 37 | - | timingText = candidate | |||
| 38 | - | remaining -= len(candidate) | |||
| 39 | - | } | |||
| 40 | - | } | |||
| 41 | - | ||||
| 42 | - | textBytes := toolResultTextBytes(result.Content) | |||
| 43 | - | if textBytes > 0 { | |||
| 44 | - | limit := maxToolResultTextBytes | |||
| 45 | - | if remaining < limit { | |||
| 46 | - | limit = remaining | |||
| 47 | - | } | |||
| 48 | - | var retained int | |||
| 49 | - | result.Content, retained = projectToolResultContent(result.Content, limit) | |||
| 50 | - | remaining -= retained | |||
| 51 | } | 33 | } | ||
| 52 | - | if timingText != "" { | 34 | + | if result.Timing != nil { |
| 53 | - | result.Content = append(result.Content, provider.TextBlock{Text: timingText}) | 35 | + | result.Content = append(result.Content, provider.TextBlock{Text: formatToolTiming(result.Timing)}) |
| 54 | } | 36 | } | ||
| 55 | projected[i].Content[j] = result | 37 | projected[i].Content[j] = result | ||
| 56 | } | 38 | } | ||
M packages/core/context_budget_test.go +36 -50
| @@ -2,6 +2,7 @@ package core | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "fmt" | |
| 5 | 6 | "reflect" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "testing" | |
| @@ -93,37 +94,28 @@ func TestProjectToolTimingIsModelOnly(t *testing.T) { | |||
| 93 | 94 | } | |
| 94 | 95 | } | |
| 95 | 96 | ||
| 96 | - | func TestProjectToolTimingCountsTowardAggregateBudget(t *testing.T) { | |
| 97 | - | started := time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC) | |
| 98 | - | timing := &provider.ToolTiming{StartedAt: started, CompletedAt: started.Add(time.Second), Duration: time.Second} | |
| 99 | - | msgs := make([]provider.Message, 0, maxToolResultTotalTextBytes/maxToolResultTextBytes+1) | |
| 100 | - | for i := 0; i < maxToolResultTotalTextBytes/maxToolResultTextBytes; i++ { | |
| 101 | - | msgs = append(msgs, provider.Message{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{ | |
| 102 | - | CallID: string(rune('a' + i)), | |
| 103 | - | Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("x", maxToolResultTextBytes)}}, | |
| 104 | - | }}}) | |
| 105 | - | } | |
| 106 | - | msgs = append(msgs, provider.Message{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{ | |
| 107 | - | CallID: "timed-empty", Timing: timing, | |
| 108 | - | }}}) | |
| 109 | - | ||
| 110 | - | projected := projectToolResultMessages(msgs) | |
| 111 | - | if got := toolResultTextForCall(projected, "timed-empty"); got != formatToolTiming(timing) { | |
| 112 | - | t.Fatalf("empty timed result = %q, want timing annotation", got) | |
| 113 | - | } | |
| 114 | - | var total int | |
| 115 | - | for _, message := range projected { | |
| 116 | - | for _, content := range message.Content { | |
| 117 | - | if result, ok := content.(provider.ToolResultBlock); ok { | |
| 118 | - | total += retainedToolResultTextBytes(result.Content) | |
| 119 | - | } | |
| 120 | - | } | |
| 121 | - | } | |
| 122 | - | if total > maxToolResultTotalTextBytes { | |
| 123 | - | t.Fatalf("projected tool-result text = %d bytes, want <= %d", total, maxToolResultTotalTextBytes) | |
| 97 | + | func TestProjectToolResultMessagesKeepsHistoricalPrefixStable(t *testing.T) { | |
| 98 | + | messages := make([]provider.Message, 0, 4) | |
| 99 | + | for i := 0; i < 4; i++ { | |
| 100 | + | messages = append(messages, provider.Message{Role: provider.RoleTool, Content: []provider.Content{ | |
| 101 | + | provider.ToolResultBlock{ | |
| 102 | + | CallID: fmt.Sprintf("call-%d", i), | |
| 103 | + | Content: []provider.Content{provider.TextBlock{Text: strings.Repeat(string(rune('a'+i)), maxToolResultTextBytes)}}, | |
| 104 | + | }, | |
| 105 | + | }}) | |
| 124 | 106 | } | |
| 125 | - | if got := toolResultTextForCall(projected, "a"); !strings.Contains(got, toolResultOmissionMarker) { | |
| 126 | - | t.Fatalf("oldest result = %q, want truncation after timing reservation", got) | |
| 107 | + | ||
| 108 | + | before := projectToolResultMessages(messages) | |
| 109 | + | messages = append(messages, provider.Message{Role: provider.RoleTool, Content: []provider.Content{ | |
| 110 | + | provider.ToolResultBlock{ | |
| 111 | + | CallID: "new-call", | |
| 112 | + | Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("new", maxToolResultTextBytes)}}, | |
| 113 | + | }, | |
| 114 | + | }}) | |
| 115 | + | after := projectToolResultMessages(messages) | |
| 116 | + | ||
| 117 | + | if !reflect.DeepEqual(after[:len(before)], before) { | |
| 118 | + | t.Fatal("appending a tool result changed the provider-visible historical prefix") | |
| 127 | 119 | } | |
| 128 | 120 | } | |
| 129 | 121 | ||
| @@ -147,7 +139,6 @@ func TestProjectToolResultMessagesBoundsAndUTF8(t *testing.T) { | |||
| 147 | 139 | } | |
| 148 | 140 | ||
| 149 | 141 | projected := projectToolResultMessages(msgs) | |
| 150 | - | total := 0 | |
| 151 | 142 | for _, message := range projected { | |
| 152 | 143 | for _, content := range message.Content { | |
| 153 | 144 | result, ok := content.(provider.ToolResultBlock) | |
| @@ -166,15 +157,11 @@ func TestProjectToolResultMessagesBoundsAndUTF8(t *testing.T) { | |||
| 166 | 157 | t.Fatalf("result %q contains invalid UTF-8", result.CallID) | |
| 167 | 158 | } | |
| 168 | 159 | } | |
| 169 | - | total += retainedBytes | |
| 170 | 160 | } | |
| 171 | 161 | } | |
| 172 | - | if total > maxToolResultTotalTextBytes { | |
| 173 | - | t.Fatalf("projected tool-result text = %d bytes, want <= %d", total, maxToolResultTotalTextBytes) | |
| 174 | - | } | |
| 175 | 162 | } | |
| 176 | 163 | ||
| 177 | - | func TestProjectToolResultMessagesRetainsNewestResultsFirst(t *testing.T) { | |
| 164 | + | func TestProjectToolResultMessagesBoundsEveryResultIndependently(t *testing.T) { | |
| 178 | 165 | msgs := make([]provider.Message, 0, 10) | |
| 179 | 166 | for i := 1; i <= 5; i++ { | |
| 180 | 167 | id := "call-" + string(rune('0'+i)) | |
| @@ -192,18 +179,17 @@ func TestProjectToolResultMessagesRetainsNewestResultsFirst(t *testing.T) { | |||
| 192 | 179 | } | |
| 193 | 180 | ||
| 194 | 181 | projected := projectToolResultMessages(msgs) | |
| 195 | - | oldest := toolResultTextForCall(projected, "call-1") | |
| 196 | - | newest := toolResultTextForCall(projected, "call-5") | |
| 197 | - | if oldest != toolResultOmissionMarker { | |
| 198 | - | t.Fatalf("oldest result = %q, want omission marker after newer results filled budget", oldest) | |
| 199 | - | } | |
| 200 | - | if !strings.HasPrefix(newest, "result-call-5 ") || !strings.Contains(newest, toolResultOmissionMarker) { | |
| 201 | - | t.Fatalf("newest result was not retained and truncated first: %q", newest[:min(len(newest), 80)]) | |
| 182 | + | for i := 1; i <= 5; i++ { | |
| 183 | + | id := "call-" + string(rune('0'+i)) | |
| 184 | + | got := toolResultTextForCall(projected, id) | |
| 185 | + | if !strings.HasPrefix(got, "result-"+id+" ") || !strings.Contains(got, toolResultOmissionMarker) { | |
| 186 | + | t.Fatalf("result %s was not independently retained and truncated: %q", id, got[:min(len(got), 80)]) | |
| 187 | + | } | |
| 202 | 188 | } | |
| 203 | 189 | } | |
| 204 | 190 | ||
| 205 | - | func TestProjectToolResultMessagesMarksResultsAfterAggregateBudgetIsExhausted(t *testing.T) { | |
| 206 | - | const resultCount = maxToolResultTotalTextBytes/maxToolResultTextBytes + 1 | |
| 191 | + | func TestProjectToolResultMessagesDoesNotExhaustAnAggregateBudget(t *testing.T) { | |
| 192 | + | const resultCount = 5 | |
| 207 | 193 | msgs := make([]provider.Message, 0, resultCount) | |
| 208 | 194 | for i := 0; i < resultCount; i++ { | |
| 209 | 195 | id := "call-" + string(rune('1'+i)) | |
| @@ -230,11 +216,11 @@ func TestProjectToolResultMessagesMarksResultsAfterAggregateBudgetIsExhausted(t | |||
| 230 | 216 | retainedTotal += retainedToolResultTextBytes(result.Content) | |
| 231 | 217 | } | |
| 232 | 218 | } | |
| 233 | - | if retainedTotal != maxToolResultTotalTextBytes { | |
| 234 | - | t.Fatalf("retained tool-result text = %d bytes, want exactly %d", retainedTotal, maxToolResultTotalTextBytes) | |
| 219 | + | if retainedTotal != resultCount*maxToolResultTextBytes { | |
| 220 | + | t.Fatalf("retained tool-result text = %d bytes, want %d independent bytes", retainedTotal, resultCount*maxToolResultTextBytes) | |
| 235 | 221 | } | |
| 236 | - | if got := toolResultTextForCall(projected, "call-1"); got != toolResultOmissionMarker { | |
| 237 | - | t.Fatalf("aggregate-exhausted result = %q, want omission marker", got) | |
| 222 | + | if got := toolResultTextForCall(projected, "call-1"); got == toolResultOmissionMarker { | |
| 223 | + | t.Fatalf("old result was discarded after later results: %q", got) | |
| 238 | 224 | } | |
| 239 | 225 | } | |
| 240 | 226 | ||
| @@ -291,7 +277,7 @@ func TestAgentNormalRequestProjectsToolResultsAfterPairRepair(t *testing.T) { | |||
| 291 | 277 | t.Fatalf("Continue: %v", err) | |
| 292 | 278 | } | |
| 293 | 279 | projectedText := toolResultTextForCall(client.lastReq.Messages, "call-1") | |
| 294 | - | if retainedToolResultTextBytesForCall(client.lastReq.Messages, "call-1") > maxToolResultTotalTextBytes || !strings.Contains(projectedText, toolResultOmissionMarker) { | |
| 280 | + | if !strings.Contains(projectedText, toolResultOmissionMarker) { | |
| 295 | 281 | t.Fatalf("normal request result was not bounded: %d bytes, %q", len(projectedText), projectedText[:min(len(projectedText), 80)]) | |
| 296 | 282 | } | |
| 297 | 283 | if !strings.Contains(projectedText, formatToolTiming(timing)) { | |
| @@ -2,6 +2,7 @@ package core | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "fmt" | |||
| 5 | "reflect" | 6 | "reflect" | ||
| 6 | "strings" | 7 | "strings" | ||
| 7 | "testing" | 8 | "testing" | ||
| @@ -93,37 +94,28 @@ func TestProjectToolTimingIsModelOnly(t *testing.T) { | |||||
| 93 | } | 94 | } | ||
| 94 | } | 95 | } | ||
| 95 | 96 | ||||
| 96 | - | func TestProjectToolTimingCountsTowardAggregateBudget(t *testing.T) { | 97 | + | func TestProjectToolResultMessagesKeepsHistoricalPrefixStable(t *testing.T) { |
| 97 | - | started := time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC) | 98 | + | messages := make([]provider.Message, 0, 4) |
| 98 | - | timing := &provider.ToolTiming{StartedAt: started, CompletedAt: started.Add(time.Second), Duration: time.Second} | 99 | + | for i := 0; i < 4; i++ { |
| 99 | - | msgs := make([]provider.Message, 0, maxToolResultTotalTextBytes/maxToolResultTextBytes+1) | 100 | + | messages = append(messages, provider.Message{Role: provider.RoleTool, Content: []provider.Content{ |
| 100 | - | for i := 0; i < maxToolResultTotalTextBytes/maxToolResultTextBytes; i++ { | 101 | + | provider.ToolResultBlock{ |
| 101 | - | msgs = append(msgs, provider.Message{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{ | 102 | + | CallID: fmt.Sprintf("call-%d", i), |
| 102 | - | CallID: string(rune('a' + i)), | 103 | + | Content: []provider.Content{provider.TextBlock{Text: strings.Repeat(string(rune('a'+i)), maxToolResultTextBytes)}}, |
| 103 | - | Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("x", maxToolResultTextBytes)}}, | 104 | + | }, |
| 104 | - | }}}) | 105 | + | }}) |
| 105 | - | } | |||
| 106 | - | msgs = append(msgs, provider.Message{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{ | |||
| 107 | - | CallID: "timed-empty", Timing: timing, | |||
| 108 | - | }}}) | |||
| 109 | - | ||||
| 110 | - | projected := projectToolResultMessages(msgs) | |||
| 111 | - | if got := toolResultTextForCall(projected, "timed-empty"); got != formatToolTiming(timing) { | |||
| 112 | - | t.Fatalf("empty timed result = %q, want timing annotation", got) | |||
| 113 | - | } | |||
| 114 | - | var total int | |||
| 115 | - | for _, message := range projected { | |||
| 116 | - | for _, content := range message.Content { | |||
| 117 | - | if result, ok := content.(provider.ToolResultBlock); ok { | |||
| 118 | - | total += retainedToolResultTextBytes(result.Content) | |||
| 119 | - | } | |||
| 120 | - | } | |||
| 121 | - | } | |||
| 122 | - | if total > maxToolResultTotalTextBytes { | |||
| 123 | - | t.Fatalf("projected tool-result text = %d bytes, want <= %d", total, maxToolResultTotalTextBytes) | |||
| 124 | } | 106 | } | ||
| 125 | - | if got := toolResultTextForCall(projected, "a"); !strings.Contains(got, toolResultOmissionMarker) { | 107 | + | |
| 126 | - | t.Fatalf("oldest result = %q, want truncation after timing reservation", got) | 108 | + | before := projectToolResultMessages(messages) |
| 109 | + | messages = append(messages, provider.Message{Role: provider.RoleTool, Content: []provider.Content{ | |||
| 110 | + | provider.ToolResultBlock{ | |||
| 111 | + | CallID: "new-call", | |||
| 112 | + | Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("new", maxToolResultTextBytes)}}, | |||
| 113 | + | }, | |||
| 114 | + | }}) | |||
| 115 | + | after := projectToolResultMessages(messages) | |||
| 116 | + | ||||
| 117 | + | if !reflect.DeepEqual(after[:len(before)], before) { | |||
| 118 | + | t.Fatal("appending a tool result changed the provider-visible historical prefix") | |||
| 127 | } | 119 | } | ||
| 128 | } | 120 | } | ||
| 129 | 121 | ||||
| @@ -147,7 +139,6 @@ func TestProjectToolResultMessagesBoundsAndUTF8(t *testing.T) { | |||||
| 147 | } | 139 | } | ||
| 148 | 140 | ||||
| 149 | projected := projectToolResultMessages(msgs) | 141 | projected := projectToolResultMessages(msgs) | ||
| 150 | - | total := 0 | |||
| 151 | for _, message := range projected { | 142 | for _, message := range projected { | ||
| 152 | for _, content := range message.Content { | 143 | for _, content := range message.Content { | ||
| 153 | result, ok := content.(provider.ToolResultBlock) | 144 | result, ok := content.(provider.ToolResultBlock) | ||
| @@ -166,15 +157,11 @@ func TestProjectToolResultMessagesBoundsAndUTF8(t *testing.T) { | |||||
| 166 | t.Fatalf("result %q contains invalid UTF-8", result.CallID) | 157 | t.Fatalf("result %q contains invalid UTF-8", result.CallID) | ||
| 167 | } | 158 | } | ||
| 168 | } | 159 | } | ||
| 169 | - | total += retainedBytes | |||
| 170 | } | 160 | } | ||
| 171 | } | 161 | } | ||
| 172 | - | if total > maxToolResultTotalTextBytes { | |||
| 173 | - | t.Fatalf("projected tool-result text = %d bytes, want <= %d", total, maxToolResultTotalTextBytes) | |||
| 174 | - | } | |||
| 175 | } | 162 | } | ||
| 176 | 163 | ||||
| 177 | - | func TestProjectToolResultMessagesRetainsNewestResultsFirst(t *testing.T) { | 164 | + | func TestProjectToolResultMessagesBoundsEveryResultIndependently(t *testing.T) { |
| 178 | msgs := make([]provider.Message, 0, 10) | 165 | msgs := make([]provider.Message, 0, 10) | ||
| 179 | for i := 1; i <= 5; i++ { | 166 | for i := 1; i <= 5; i++ { | ||
| 180 | id := "call-" + string(rune('0'+i)) | 167 | id := "call-" + string(rune('0'+i)) | ||
| @@ -192,18 +179,17 @@ func TestProjectToolResultMessagesRetainsNewestResultsFirst(t *testing.T) { | |||||
| 192 | } | 179 | } | ||
| 193 | 180 | ||||
| 194 | projected := projectToolResultMessages(msgs) | 181 | projected := projectToolResultMessages(msgs) | ||
| 195 | - | oldest := toolResultTextForCall(projected, "call-1") | 182 | + | for i := 1; i <= 5; i++ { |
| 196 | - | newest := toolResultTextForCall(projected, "call-5") | 183 | + | id := "call-" + string(rune('0'+i)) |
| 197 | - | if oldest != toolResultOmissionMarker { | 184 | + | got := toolResultTextForCall(projected, id) |
| 198 | - | t.Fatalf("oldest result = %q, want omission marker after newer results filled budget", oldest) | 185 | + | if !strings.HasPrefix(got, "result-"+id+" ") || !strings.Contains(got, toolResultOmissionMarker) { |
| 199 | - | } | 186 | + | t.Fatalf("result %s was not independently retained and truncated: %q", id, got[:min(len(got), 80)]) |
| 200 | - | if !strings.HasPrefix(newest, "result-call-5 ") || !strings.Contains(newest, toolResultOmissionMarker) { | 187 | + | } |
| 201 | - | t.Fatalf("newest result was not retained and truncated first: %q", newest[:min(len(newest), 80)]) | |||
| 202 | } | 188 | } | ||
| 203 | } | 189 | } | ||
| 204 | 190 | ||||
| 205 | - | func TestProjectToolResultMessagesMarksResultsAfterAggregateBudgetIsExhausted(t *testing.T) { | 191 | + | func TestProjectToolResultMessagesDoesNotExhaustAnAggregateBudget(t *testing.T) { |
| 206 | - | const resultCount = maxToolResultTotalTextBytes/maxToolResultTextBytes + 1 | 192 | + | const resultCount = 5 |
| 207 | msgs := make([]provider.Message, 0, resultCount) | 193 | msgs := make([]provider.Message, 0, resultCount) | ||
| 208 | for i := 0; i < resultCount; i++ { | 194 | for i := 0; i < resultCount; i++ { | ||
| 209 | id := "call-" + string(rune('1'+i)) | 195 | id := "call-" + string(rune('1'+i)) | ||
| @@ -230,11 +216,11 @@ func TestProjectToolResultMessagesMarksResultsAfterAggregateBudgetIsExhausted(t | |||||
| 230 | retainedTotal += retainedToolResultTextBytes(result.Content) | 216 | retainedTotal += retainedToolResultTextBytes(result.Content) | ||
| 231 | } | 217 | } | ||
| 232 | } | 218 | } | ||
| 233 | - | if retainedTotal != maxToolResultTotalTextBytes { | 219 | + | if retainedTotal != resultCount*maxToolResultTextBytes { |
| 234 | - | t.Fatalf("retained tool-result text = %d bytes, want exactly %d", retainedTotal, maxToolResultTotalTextBytes) | 220 | + | t.Fatalf("retained tool-result text = %d bytes, want %d independent bytes", retainedTotal, resultCount*maxToolResultTextBytes) |
| 235 | } | 221 | } | ||
| 236 | - | if got := toolResultTextForCall(projected, "call-1"); got != toolResultOmissionMarker { | 222 | + | if got := toolResultTextForCall(projected, "call-1"); got == toolResultOmissionMarker { |
| 237 | - | t.Fatalf("aggregate-exhausted result = %q, want omission marker", got) | 223 | + | t.Fatalf("old result was discarded after later results: %q", got) |
| 238 | } | 224 | } | ||
| 239 | } | 225 | } | ||
| 240 | 226 | ||||
| @@ -291,7 +277,7 @@ func TestAgentNormalRequestProjectsToolResultsAfterPairRepair(t *testing.T) { | |||||
| 291 | t.Fatalf("Continue: %v", err) | 277 | t.Fatalf("Continue: %v", err) | ||
| 292 | } | 278 | } | ||
| 293 | projectedText := toolResultTextForCall(client.lastReq.Messages, "call-1") | 279 | projectedText := toolResultTextForCall(client.lastReq.Messages, "call-1") | ||
| 294 | - | if retainedToolResultTextBytesForCall(client.lastReq.Messages, "call-1") > maxToolResultTotalTextBytes || !strings.Contains(projectedText, toolResultOmissionMarker) { | 280 | + | if !strings.Contains(projectedText, toolResultOmissionMarker) { |
| 295 | t.Fatalf("normal request result was not bounded: %d bytes, %q", len(projectedText), projectedText[:min(len(projectedText), 80)]) | 281 | t.Fatalf("normal request result was not bounded: %d bytes, %q", len(projectedText), projectedText[:min(len(projectedText), 80)]) | ||
| 296 | } | 282 | } | ||
| 297 | if !strings.Contains(projectedText, formatToolTiming(timing)) { | 283 | if !strings.Contains(projectedText, formatToolTiming(timing)) { | ||