detached 1a6392e

fix(core): preserve provider prompt prefixes

commit
1a6392e94847c3e95db12d29c8799047b354c23c
author
brice · · 2026-08-18 13:17 UTC
parent
aa7307c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +49 -81
M packages/core/context_budget.go +13 -31
@@ -9,48 +9,30 @@ import (
99 )
1010
1111 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]"
1514 )
1615
1716 // 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.
2221 func projectToolResultMessages(msgs []provider.Message) []provider.Message {
2322 projected := copyToolResultMessages(msgs)
24- remaining := maxToolResultTotalTextBytes
2523
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)
2927 if !ok {
3028 continue
3129 }
3230
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)
5133 }
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)})
5436 }
5537 projected[i].Content[j] = result
5638 }
@@ -9,48 +9,30 @@ import (
9 )9 )
10 10
11 const (11 const (
12- maxToolResultTextBytes = 32 * 102412+ maxToolResultTextBytes = 32 * 1024
13- maxToolResultTotalTextBytes = 128 * 102413+ 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 the17+// 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 so19+// 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 continue28 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] = result37 projected[i].Content[j] = result
56 }38 }
M packages/core/context_budget_test.go +36 -50
@@ -2,6 +2,7 @@ package core
22
33 import (
44 "context"
5+ "fmt"
56 "reflect"
67 "strings"
78 "testing"
@@ -93,37 +94,28 @@ func TestProjectToolTimingIsModelOnly(t *testing.T) {
9394 }
9495 }
9596
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+ }})
124106 }
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")
127119 }
128120 }
129121
@@ -147,7 +139,6 @@ func TestProjectToolResultMessagesBoundsAndUTF8(t *testing.T) {
147139 }
148140
149141 projected := projectToolResultMessages(msgs)
150- total := 0
151142 for _, message := range projected {
152143 for _, content := range message.Content {
153144 result, ok := content.(provider.ToolResultBlock)
@@ -166,15 +157,11 @@ func TestProjectToolResultMessagesBoundsAndUTF8(t *testing.T) {
166157 t.Fatalf("result %q contains invalid UTF-8", result.CallID)
167158 }
168159 }
169- total += retainedBytes
170160 }
171161 }
172- if total > maxToolResultTotalTextBytes {
173- t.Fatalf("projected tool-result text = %d bytes, want <= %d", total, maxToolResultTotalTextBytes)
174- }
175162 }
176163
177-func TestProjectToolResultMessagesRetainsNewestResultsFirst(t *testing.T) {
164+func TestProjectToolResultMessagesBoundsEveryResultIndependently(t *testing.T) {
178165 msgs := make([]provider.Message, 0, 10)
179166 for i := 1; i <= 5; i++ {
180167 id := "call-" + string(rune('0'+i))
@@ -192,18 +179,17 @@ func TestProjectToolResultMessagesRetainsNewestResultsFirst(t *testing.T) {
192179 }
193180
194181 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+ }
202188 }
203189 }
204190
205-func TestProjectToolResultMessagesMarksResultsAfterAggregateBudgetIsExhausted(t *testing.T) {
206- const resultCount = maxToolResultTotalTextBytes/maxToolResultTextBytes + 1
191+func TestProjectToolResultMessagesDoesNotExhaustAnAggregateBudget(t *testing.T) {
192+ const resultCount = 5
207193 msgs := make([]provider.Message, 0, resultCount)
208194 for i := 0; i < resultCount; i++ {
209195 id := "call-" + string(rune('1'+i))
@@ -230,11 +216,11 @@ func TestProjectToolResultMessagesMarksResultsAfterAggregateBudgetIsExhausted(t
230216 retainedTotal += retainedToolResultTextBytes(result.Content)
231217 }
232218 }
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)
235221 }
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)
238224 }
239225 }
240226
@@ -291,7 +277,7 @@ func TestAgentNormalRequestProjectsToolResultsAfterPairRepair(t *testing.T) {
291277 t.Fatalf("Continue: %v", err)
292278 }
293279 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) {
295281 t.Fatalf("normal request result was not bounded: %d bytes, %q", len(projectedText), projectedText[:min(len(projectedText), 80)])
296282 }
297283 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 + 1192+ 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)) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close