v0.13.0 befd698

context_budget_test.go RawHistoryFolder

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

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