main 4509ec7

context_budget_test.go RawHistoryFolder

1package core
2
3import (
4	"context"
5	"fmt"
6	"reflect"
7	"strings"
8	"testing"
9	"time"
10	"unicode/utf8"
11
12	"github.com/bnema/zut/packages/provider"
13)
14
15func TestProjectProviderMessagesAnnotatesUserTimeWithoutMutatingTranscript(t *testing.T) {
16	zone := time.FixedZone("PDT", -7*60*60)
17	accepted := time.Date(2026, 8, 8, 12, 34, 56, 0, zone)
18	messages := []provider.Message{{
19		Role:    provider.RoleUser,
20		Content: []provider.Content{provider.TextBlock{Text: "hello"}},
21		Time:    accepted,
22	}}
23
24	projected := projectProviderMessages(messages)
25	if got := messages[0].Content[0].(provider.TextBlock).Text; got != "hello" {
26		t.Fatalf("raw user text changed to %q", got)
27	}
28	if len(projected[0].Content) != 2 {
29		t.Fatalf("projected content = %d blocks, want timestamp and original", len(projected[0].Content))
30	}
31	annotation := projected[0].Content[0].(provider.TextBlock).Text
32	if annotation != "[message time: 2026-08-08T12:34:56-07:00]" {
33		t.Fatalf("timestamp annotation = %q", annotation)
34	}
35	if got := projected[0].Content[1].(provider.TextBlock).Text; got != "hello" {
36		t.Fatalf("projected user text = %q", got)
37	}
38}
39
40func TestAgentProviderTimeContextIsStableAndLocal(t *testing.T) {
41	a := NewAgent(nil, "model", "system", Registry{})
42	started := time.Date(2026, 8, 8, 9, 10, 11, 0, time.UTC)
43	a.SetSessionTimeContext(started, "America/Los_Angeles", "-07:00")
44
45	first := a.providerTimeContext().systemText()
46	second := a.providerTimeContext().systemText()
47	if first != second {
48		t.Fatalf("time context changed between requests: %q != %q", first, second)
49	}
50	for _, want := range []string{"session_started: 2026-08-08 02:10", "local_timezone: America/Los_Angeles (UTC-07:00)"} {
51		if !strings.Contains(first, want) {
52			t.Fatalf("time context %q missing %q", first, want)
53		}
54	}
55}
56
57func TestSessionTimeContextUsesNamedTimezoneForProjectedWinterMessages(t *testing.T) {
58	a := NewAgent(nil, "model", "", Registry{})
59	started := time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC)
60	a.SetSessionTimeContext(started, "America/Los_Angeles", "-07:00")
61
62	context := a.providerTimeContext()
63	december := time.Date(2026, 12, 8, 12, 0, 0, 0, time.UTC).In(context.location)
64	projected := projectProviderMessages([]provider.Message{{
65		Role:    provider.RoleUser,
66		Content: []provider.Content{provider.TextBlock{Text: "winter message"}},
67		Time:    december,
68	}})
69	annotation := projected[0].Content[0].(provider.TextBlock).Text
70	if annotation != "[message time: 2026-12-08T04:00:00-08:00]" {
71		t.Fatalf("winter message annotation = %q, want Pacific Standard Time", annotation)
72	}
73}
74
75func TestProjectToolTimingIsModelOnly(t *testing.T) {
76	started := time.Date(2026, 8, 8, 12, 0, 0, 0, time.FixedZone("UTC-4", -4*60*60))
77	timing := &provider.ToolTiming{StartedAt: started, CompletedAt: started.Add(1500 * time.Millisecond), Duration: 1500 * time.Millisecond}
78	messages := []provider.Message{{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
79		CallID: "call-1", Content: []provider.Content{provider.TextBlock{Text: "output"}}, Timing: timing,
80	}}}}
81
82	projected := projectProviderMessages(messages)
83	original := messages[0].Content[0].(provider.ToolResultBlock)
84	if len(original.Content) != 1 {
85		t.Fatalf("raw result content was changed: %#v", original.Content)
86	}
87	result := projected[0].Content[0].(provider.ToolResultBlock)
88	if len(result.Content) != 2 {
89		t.Fatalf("projected result content = %d blocks, want output and timing", len(result.Content))
90	}
91	timingText := result.Content[1].(provider.TextBlock).Text
92	if !strings.Contains(timingText, "started=2026-08-08T12:00:00-04:00") || !strings.Contains(timingText, "duration=1.5s") {
93		t.Fatalf("timing projection = %q", timingText)
94	}
95}
96
97func TestProjectToolResultMessagesOmitsSupersededWorkspaceViews(t *testing.T) {
98	path := "/workspace/main.go"
99	messages := []provider.Message{{Role: provider.RoleTool, Content: []provider.Content{
100		provider.ToolResultBlock{CallID: "search", Content: []provider.Content{provider.TextBlock{Text: "main.go:10"}}, Context: provider.ToolContext{Discovers: []string{path}}},
101		provider.ToolResultBlock{CallID: "read-old", Content: []provider.Content{provider.TextBlock{Text: "old"}}, Context: provider.ToolContext{Reads: []provider.ResourceRef{{Key: path}}}},
102		provider.ToolResultBlock{CallID: "write", Content: []provider.Content{provider.TextBlock{Text: "changed"}}, Context: provider.ToolContext{Mutates: []string{path}}},
103		provider.ToolResultBlock{CallID: "read-new", Content: []provider.Content{provider.TextBlock{Text: "new"}}, Context: provider.ToolContext{Reads: []provider.ResourceRef{{Key: path}}}},
104	}}}
105
106	projected := projectToolResultMessages(messages)
107	for _, id := range []string{"search", "read-old"} {
108		if got := toolResultTextForCall(projected, id); got != staleToolResultMarker {
109			t.Fatalf("projected %s = %q, want stale marker", id, got)
110		}
111	}
112	if got := toolResultTextForCall(projected, "write"); got != "changed" {
113		t.Fatalf("mutation result = %q", got)
114	}
115	if got := toolResultTextForCall(projected, "read-new"); got != "new" {
116		t.Fatalf("latest read = %q", got)
117	}
118	if got := toolResultTextForCall(messages, "read-old"); got != "old" {
119		t.Fatalf("durable transcript was mutated: %q", got)
120	}
121}
122
123func TestProjectToolResultMessagesKeepsDiscoveryUntilAllFilesConsumed(t *testing.T) {
124	first, second := "/workspace/a.go", "/workspace/b.go"
125	messages := []provider.Message{{Role: provider.RoleTool, Content: []provider.Content{
126		provider.ToolResultBlock{CallID: "search", Content: []provider.Content{provider.TextBlock{Text: "a.go\nb.go"}}, Context: provider.ToolContext{Discovers: []string{first, second}}},
127		provider.ToolResultBlock{CallID: "read-a", Content: []provider.Content{provider.TextBlock{Text: "a"}}, Context: provider.ToolContext{Reads: []provider.ResourceRef{{Key: first}}}},
128	}}}
129	if got := toolResultTextForCall(projectToolResultMessages(messages), "search"); got != "a.go\nb.go" {
130		t.Fatalf("partially consumed search = %q", got)
131	}
132	messages[0].Content = append(messages[0].Content, provider.ToolResultBlock{CallID: "read-b", Content: []provider.Content{provider.TextBlock{Text: "b"}}, Context: provider.ToolContext{Reads: []provider.ResourceRef{{Key: second}}}})
133	if got := toolResultTextForCall(projectToolResultMessages(messages), "search"); got != staleToolResultMarker {
134		t.Fatalf("fully consumed search = %q", got)
135	}
136}
137
138func TestProjectToolResultMessagesDoesNotStaleCurrentReadMutation(t *testing.T) {
139	path := "/workspace/a.go"
140	messages := []provider.Message{{Role: provider.RoleTool, Content: []provider.Content{
141		provider.ToolResultBlock{CallID: "current", Content: []provider.Content{provider.TextBlock{Text: "updated"}}, Context: provider.ToolContext{Reads: []provider.ResourceRef{{Key: path}}, Mutates: []string{path}}},
142	}}}
143	if got := toolResultTextForCall(projectToolResultMessages(messages), "current"); got != "updated" {
144		t.Fatalf("current result = %q", got)
145	}
146}
147
148func TestProjectToolResultMessagesPreservesDistinctReadVariants(t *testing.T) {
149	path := "/workspace/main.go"
150	messages := []provider.Message{{Role: provider.RoleTool, Content: []provider.Content{
151		provider.ToolResultBlock{CallID: "first", Content: []provider.Content{provider.TextBlock{Text: "lines 1-10"}}, Context: provider.ToolContext{Reads: []provider.ResourceRef{{Key: path, Variant: "1:10"}}}},
152		provider.ToolResultBlock{CallID: "second", Content: []provider.Content{provider.TextBlock{Text: "lines 20-30"}}, Context: provider.ToolContext{Reads: []provider.ResourceRef{{Key: path, Variant: "20:10"}}}},
153	}}}
154	projected := projectToolResultMessages(messages)
155	if got := toolResultTextForCall(projected, "first"); got != "lines 1-10" {
156		t.Fatalf("distinct range was omitted: %q", got)
157	}
158}
159
160func TestProjectToolResultMessagesKeepsHistoricalPrefixStable(t *testing.T) {
161	messages := make([]provider.Message, 0, 4)
162	for i := 0; i < 4; i++ {
163		messages = append(messages, provider.Message{Role: provider.RoleTool, Content: []provider.Content{
164			provider.ToolResultBlock{
165				CallID:  fmt.Sprintf("call-%d", i),
166				Content: []provider.Content{provider.TextBlock{Text: strings.Repeat(string(rune('a'+i)), maxToolResultTextBytes)}},
167			},
168		}})
169	}
170
171	before := projectToolResultMessages(messages)
172	messages = append(messages, provider.Message{Role: provider.RoleTool, Content: []provider.Content{
173		provider.ToolResultBlock{
174			CallID:  "new-call",
175			Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("new", maxToolResultTextBytes)}},
176		},
177	}})
178	after := projectToolResultMessages(messages)
179
180	if !reflect.DeepEqual(after[:len(before)], before) {
181		t.Fatal("appending a tool result changed the provider-visible historical prefix")
182	}
183}
184
185func TestProjectToolResultMessagesBoundsAndUTF8(t *testing.T) {
186	msgs := make([]provider.Message, 0, 10)
187	for i := 0; i < 5; i++ {
188		id := "call-" + string(rune('1'+i))
189		msgs = append(msgs,
190			provider.Message{
191				Role:    provider.RoleAssistant,
192				Content: []provider.Content{provider.ToolCallBlock{ID: id, Name: "read"}},
193			},
194			provider.Message{
195				Role: provider.RoleTool,
196				Content: []provider.Content{provider.ToolResultBlock{
197					CallID:  id,
198					Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("界", maxToolResultTextBytes+100)}},
199				}},
200			},
201		)
202	}
203
204	projected := projectToolResultMessages(msgs)
205	for _, message := range projected {
206		for _, content := range message.Content {
207			result, ok := content.(provider.ToolResultBlock)
208			if !ok {
209				continue
210			}
211			retainedBytes := retainedToolResultTextBytes(result.Content)
212			if retainedBytes > maxToolResultTextBytes {
213				t.Fatalf("result %q retains %d text bytes, want <= %d", result.CallID, retainedBytes, maxToolResultTextBytes)
214			}
215			if !hasToolResultOmissionMarker(result.Content) {
216				t.Fatalf("truncated result %q is missing the omission marker", result.CallID)
217			}
218			for _, inner := range result.Content {
219				if text, ok := inner.(provider.TextBlock); ok && !utf8.ValidString(text.Text) {
220					t.Fatalf("result %q contains invalid UTF-8", result.CallID)
221				}
222			}
223		}
224	}
225}
226
227func TestProjectToolResultMessagesBoundsEveryResultIndependently(t *testing.T) {
228	msgs := make([]provider.Message, 0, 10)
229	for i := 1; i <= 5; i++ {
230		id := "call-" + string(rune('0'+i))
231		text := strings.Repeat("result-"+id+" ", maxToolResultTextBytes/4)
232		msgs = append(msgs,
233			provider.Message{
234				Role:    provider.RoleAssistant,
235				Content: []provider.Content{provider.ToolCallBlock{ID: id, Name: "read"}},
236			},
237			provider.Message{
238				Role:    provider.RoleTool,
239				Content: []provider.Content{provider.ToolResultBlock{CallID: id, Content: []provider.Content{provider.TextBlock{Text: text}}}},
240			},
241		)
242	}
243
244	projected := projectToolResultMessages(msgs)
245	for i := 1; i <= 5; i++ {
246		id := "call-" + string(rune('0'+i))
247		got := toolResultTextForCall(projected, id)
248		if !strings.HasPrefix(got, "result-"+id+" ") || !strings.Contains(got, toolResultOmissionMarker) {
249			t.Fatalf("result %s was not independently retained and truncated: %q", id, got[:min(len(got), 80)])
250		}
251	}
252}
253
254func TestProjectToolResultMessagesDoesNotExhaustAnAggregateBudget(t *testing.T) {
255	const resultCount = 5
256	msgs := make([]provider.Message, 0, resultCount)
257	for i := 0; i < resultCount; i++ {
258		id := "call-" + string(rune('1'+i))
259		msgs = append(msgs, provider.Message{
260			Role: provider.RoleTool,
261			Content: []provider.Content{provider.ToolResultBlock{
262				CallID:  id,
263				Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("x", maxToolResultTextBytes+1)}},
264			}},
265		})
266	}
267
268	projected := projectToolResultMessages(msgs)
269	retainedTotal := 0
270	for _, message := range projected {
271		for _, content := range message.Content {
272			result, ok := content.(provider.ToolResultBlock)
273			if !ok {
274				continue
275			}
276			if !hasToolResultOmissionMarker(result.Content) {
277				t.Fatalf("result %q is missing the omission marker", result.CallID)
278			}
279			retainedTotal += retainedToolResultTextBytes(result.Content)
280		}
281	}
282	if retainedTotal != resultCount*maxToolResultTextBytes {
283		t.Fatalf("retained tool-result text = %d bytes, want %d independent bytes", retainedTotal, resultCount*maxToolResultTextBytes)
284	}
285	if got := toolResultTextForCall(projected, "call-1"); got == toolResultOmissionMarker {
286		t.Fatalf("old result was discarded after later results: %q", got)
287	}
288}
289
290func TestProjectToolResultMessagesPreservesToolResultBlocksAndRawInput(t *testing.T) {
291	msgs := []provider.Message{
292		{Role: provider.RoleAssistant, Content: []provider.Content{
293			provider.ToolCallBlock{ID: "call-1", Name: "read"},
294			provider.ToolCallBlock{ID: "call-2", Name: "bash"},
295		}},
296		{Role: provider.RoleTool, Content: []provider.Content{
297			provider.ToolResultBlock{CallID: "call-1", Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("one", maxToolResultTextBytes)}}},
298			provider.ToolResultBlock{CallID: "call-2", Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("two", maxToolResultTextBytes)}}},
299		}},
300	}
301	wantRaw := append([]provider.Message(nil), msgs...)
302
303	projected := projectToolResultMessages(msgs)
304	if !reflect.DeepEqual(msgs, wantRaw) {
305		t.Fatal("projection mutated the raw input transcript")
306	}
307	var resultIDs []string
308	for _, content := range projected[1].Content {
309		result, ok := content.(provider.ToolResultBlock)
310		if !ok {
311			t.Fatalf("projected content = %T, want ToolResultBlock", content)
312		}
313		resultIDs = append(resultIDs, result.CallID)
314	}
315	if !reflect.DeepEqual(resultIDs, []string{"call-1", "call-2"}) {
316		t.Fatalf("projected result IDs = %v, want both tool results in order", resultIDs)
317	}
318	if got := toolResultTextForCall(msgs, "call-1"); got != strings.Repeat("one", maxToolResultTextBytes) {
319		t.Fatal("raw tool result text changed after projection")
320	}
321}
322
323func TestAgentNormalRequestProjectsToolResultsAfterPairRepair(t *testing.T) {
324	client := &captureClient{}
325	agent := NewAgent(client, "model", "system", Registry{})
326	full := strings.Repeat("full-normal-result ", maxToolResultTextBytes)
327	timing := &provider.ToolTiming{StartedAt: time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC), CompletedAt: time.Date(2026, 8, 8, 12, 0, 1, 0, time.UTC), Duration: time.Second}
328	agent.SetMessages([]provider.Message{
329		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "continue"}}},
330		{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "read"}}},
331		{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
332			CallID:  "call-1",
333			Content: []provider.Content{provider.TextBlock{Text: full}},
334			Timing:  timing,
335		}}},
336		{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-2", Name: "read"}}},
337	})
338
339	if err := agent.Continue(context.Background(), nil); err != nil {
340		t.Fatalf("Continue: %v", err)
341	}
342	projectedText := toolResultTextForCall(client.lastReq.Messages, "call-1")
343	if !strings.Contains(projectedText, toolResultOmissionMarker) {
344		t.Fatalf("normal request result was not bounded: %d bytes, %q", len(projectedText), projectedText[:min(len(projectedText), 80)])
345	}
346	if !strings.Contains(projectedText, formatToolTiming(timing)) {
347		t.Fatal("normal request is missing the tool timing annotation")
348	}
349	if !hasToolResultCall(client.lastReq.Messages, "call-2") {
350		t.Fatal("pair repair result was not preserved in normal request")
351	}
352	if got := toolResultTextForCall(agent.Messages(), "call-1"); got != full {
353		t.Fatalf("raw Agent.Messages tool result changed: got %d bytes, want %d", len(got), len(full))
354	}
355	if hasToolResultCall(agent.Messages(), "call-2") {
356		t.Fatal("pair-repair stub leaked into raw Agent.Messages")
357	}
358}
359
360func TestCompactionProjectsSummaryInputWithoutChangingPersistedTail(t *testing.T) {
361	client := &compactLifecycleClient{}
362	agent := NewAgent(client, "model", "system", Registry{})
363	oldText := strings.Repeat("full-compaction-result ", maxToolResultTextBytes)
364	timing := &provider.ToolTiming{StartedAt: time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC), CompletedAt: time.Date(2026, 8, 8, 12, 0, 1, 0, time.UTC), Duration: time.Second}
365	tailText := strings.Repeat("full-tail-result ", maxToolResultTextBytes)
366	agent.SetMessages([]provider.Message{
367		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "summarize this"}}},
368		{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "old-call", Name: "read"}}},
369		{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
370			CallID:  "old-call",
371			Content: []provider.Content{provider.TextBlock{Text: oldText}},
372			Timing:  timing,
373		}}},
374		{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "tail-call", Name: "read"}}},
375		{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
376			CallID:  "tail-call",
377			Content: []provider.Content{provider.TextBlock{Text: tailText}},
378		}}},
379	})
380	var persisted []provider.Message
381	agent.OnTranscriptCompacted = func(messages []provider.Message) {
382		persisted = messages
383	}
384
385	if _, err := agent.Compact(context.Background(), 2, nil); err != nil {
386		t.Fatalf("Compact: %v", err)
387	}
388	requestText := client.req.Messages[0].Content[0].(provider.TextBlock).Text
389	if !strings.Contains(requestText, toolResultOmissionMarker) {
390		t.Fatal("compaction request is missing the tool-result omission marker")
391	}
392	if !strings.Contains(requestText, formatToolTiming(timing)) {
393		t.Fatal("compaction request is missing the tool timing annotation")
394	}
395	if got := toolResultTextForCall(agent.Messages(), "tail-call"); got != tailText {
396		t.Fatalf("compacted Agent.Messages tail changed: got %d bytes, want %d", len(got), len(tailText))
397	}
398	if got := toolResultTextForCall(persisted, "tail-call"); got != tailText {
399		t.Fatalf("persisted compacted tail changed: got %d bytes, want %d", len(got), len(tailText))
400	}
401	if hasToolResultCall(agent.Messages(), "old-call") {
402		t.Fatal("summarized tool result remained in the compacted transcript")
403	}
404}
405
406func retainedToolResultTextBytes(content []provider.Content) int {
407	total := 0
408	for _, block := range content {
409		if text, ok := block.(provider.TextBlock); ok && text.Text != toolResultOmissionMarker {
410			total += len(text.Text)
411		}
412	}
413	return total
414}
415
416func hasToolResultOmissionMarker(content []provider.Content) bool {
417	for _, block := range content {
418		if text, ok := block.(provider.TextBlock); ok && text.Text == toolResultOmissionMarker {
419			return true
420		}
421	}
422	return false
423}
424
425func toolResultTextForCall(messages []provider.Message, callID string) string {
426	var text strings.Builder
427	for _, message := range messages {
428		for _, content := range message.Content {
429			result, ok := content.(provider.ToolResultBlock)
430			if !ok || result.CallID != callID {
431				continue
432			}
433			for _, inner := range result.Content {
434				if block, ok := inner.(provider.TextBlock); ok {
435					text.WriteString(block.Text)
436				}
437			}
438		}
439	}
440	return text.String()
441}
442
443func hasToolResultCall(messages []provider.Message, callID string) bool {
444	for _, message := range messages {
445		for _, content := range message.Content {
446			if result, ok := content.(provider.ToolResultBlock); ok && result.CallID == callID {
447				return true
448			}
449		}
450	}
451	return false
452}
453
454func min(a, b int) int {
455	if a < b {
456		return a
457	}
458	return b
459}

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