v0.21.0 cd9706d

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 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		}})
106	}
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")
119	}
120}
121
122func TestProjectToolResultMessagesBoundsAndUTF8(t *testing.T) {
123	msgs := make([]provider.Message, 0, 10)
124	for i := 0; i < 5; i++ {
125		id := "call-" + string(rune('1'+i))
126		msgs = append(msgs,
127			provider.Message{
128				Role:    provider.RoleAssistant,
129				Content: []provider.Content{provider.ToolCallBlock{ID: id, Name: "read"}},
130			},
131			provider.Message{
132				Role: provider.RoleTool,
133				Content: []provider.Content{provider.ToolResultBlock{
134					CallID:  id,
135					Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("界", maxToolResultTextBytes+100)}},
136				}},
137			},
138		)
139	}
140
141	projected := projectToolResultMessages(msgs)
142	for _, message := range projected {
143		for _, content := range message.Content {
144			result, ok := content.(provider.ToolResultBlock)
145			if !ok {
146				continue
147			}
148			retainedBytes := retainedToolResultTextBytes(result.Content)
149			if retainedBytes > maxToolResultTextBytes {
150				t.Fatalf("result %q retains %d text bytes, want <= %d", result.CallID, retainedBytes, maxToolResultTextBytes)
151			}
152			if !hasToolResultOmissionMarker(result.Content) {
153				t.Fatalf("truncated result %q is missing the omission marker", result.CallID)
154			}
155			for _, inner := range result.Content {
156				if text, ok := inner.(provider.TextBlock); ok && !utf8.ValidString(text.Text) {
157					t.Fatalf("result %q contains invalid UTF-8", result.CallID)
158				}
159			}
160		}
161	}
162}
163
164func TestProjectToolResultMessagesBoundsEveryResultIndependently(t *testing.T) {
165	msgs := make([]provider.Message, 0, 10)
166	for i := 1; i <= 5; i++ {
167		id := "call-" + string(rune('0'+i))
168		text := strings.Repeat("result-"+id+" ", maxToolResultTextBytes/4)
169		msgs = append(msgs,
170			provider.Message{
171				Role:    provider.RoleAssistant,
172				Content: []provider.Content{provider.ToolCallBlock{ID: id, Name: "read"}},
173			},
174			provider.Message{
175				Role:    provider.RoleTool,
176				Content: []provider.Content{provider.ToolResultBlock{CallID: id, Content: []provider.Content{provider.TextBlock{Text: text}}}},
177			},
178		)
179	}
180
181	projected := projectToolResultMessages(msgs)
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		}
188	}
189}
190
191func TestProjectToolResultMessagesDoesNotExhaustAnAggregateBudget(t *testing.T) {
192	const resultCount = 5
193	msgs := make([]provider.Message, 0, resultCount)
194	for i := 0; i < resultCount; i++ {
195		id := "call-" + string(rune('1'+i))
196		msgs = append(msgs, provider.Message{
197			Role: provider.RoleTool,
198			Content: []provider.Content{provider.ToolResultBlock{
199				CallID:  id,
200				Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("x", maxToolResultTextBytes+1)}},
201			}},
202		})
203	}
204
205	projected := projectToolResultMessages(msgs)
206	retainedTotal := 0
207	for _, message := range projected {
208		for _, content := range message.Content {
209			result, ok := content.(provider.ToolResultBlock)
210			if !ok {
211				continue
212			}
213			if !hasToolResultOmissionMarker(result.Content) {
214				t.Fatalf("result %q is missing the omission marker", result.CallID)
215			}
216			retainedTotal += retainedToolResultTextBytes(result.Content)
217		}
218	}
219	if retainedTotal != resultCount*maxToolResultTextBytes {
220		t.Fatalf("retained tool-result text = %d bytes, want %d independent bytes", retainedTotal, resultCount*maxToolResultTextBytes)
221	}
222	if got := toolResultTextForCall(projected, "call-1"); got == toolResultOmissionMarker {
223		t.Fatalf("old result was discarded after later results: %q", got)
224	}
225}
226
227func TestProjectToolResultMessagesPreservesToolResultBlocksAndRawInput(t *testing.T) {
228	msgs := []provider.Message{
229		{Role: provider.RoleAssistant, Content: []provider.Content{
230			provider.ToolCallBlock{ID: "call-1", Name: "read"},
231			provider.ToolCallBlock{ID: "call-2", Name: "bash"},
232		}},
233		{Role: provider.RoleTool, Content: []provider.Content{
234			provider.ToolResultBlock{CallID: "call-1", Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("one", maxToolResultTextBytes)}}},
235			provider.ToolResultBlock{CallID: "call-2", Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("two", maxToolResultTextBytes)}}},
236		}},
237	}
238	wantRaw := append([]provider.Message(nil), msgs...)
239
240	projected := projectToolResultMessages(msgs)
241	if !reflect.DeepEqual(msgs, wantRaw) {
242		t.Fatal("projection mutated the raw input transcript")
243	}
244	var resultIDs []string
245	for _, content := range projected[1].Content {
246		result, ok := content.(provider.ToolResultBlock)
247		if !ok {
248			t.Fatalf("projected content = %T, want ToolResultBlock", content)
249		}
250		resultIDs = append(resultIDs, result.CallID)
251	}
252	if !reflect.DeepEqual(resultIDs, []string{"call-1", "call-2"}) {
253		t.Fatalf("projected result IDs = %v, want both tool results in order", resultIDs)
254	}
255	if got := toolResultTextForCall(msgs, "call-1"); got != strings.Repeat("one", maxToolResultTextBytes) {
256		t.Fatal("raw tool result text changed after projection")
257	}
258}
259
260func TestAgentNormalRequestProjectsToolResultsAfterPairRepair(t *testing.T) {
261	client := &captureClient{}
262	agent := NewAgent(client, "model", "system", Registry{})
263	full := strings.Repeat("full-normal-result ", maxToolResultTextBytes)
264	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}
265	agent.SetMessages([]provider.Message{
266		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "continue"}}},
267		{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "read"}}},
268		{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
269			CallID:  "call-1",
270			Content: []provider.Content{provider.TextBlock{Text: full}},
271			Timing:  timing,
272		}}},
273		{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-2", Name: "read"}}},
274	})
275
276	if err := agent.Continue(context.Background(), nil); err != nil {
277		t.Fatalf("Continue: %v", err)
278	}
279	projectedText := toolResultTextForCall(client.lastReq.Messages, "call-1")
280	if !strings.Contains(projectedText, toolResultOmissionMarker) {
281		t.Fatalf("normal request result was not bounded: %d bytes, %q", len(projectedText), projectedText[:min(len(projectedText), 80)])
282	}
283	if !strings.Contains(projectedText, formatToolTiming(timing)) {
284		t.Fatal("normal request is missing the tool timing annotation")
285	}
286	if !hasToolResultCall(client.lastReq.Messages, "call-2") {
287		t.Fatal("pair repair result was not preserved in normal request")
288	}
289	if got := toolResultTextForCall(agent.Messages(), "call-1"); got != full {
290		t.Fatalf("raw Agent.Messages tool result changed: got %d bytes, want %d", len(got), len(full))
291	}
292	if hasToolResultCall(agent.Messages(), "call-2") {
293		t.Fatal("pair-repair stub leaked into raw Agent.Messages")
294	}
295}
296
297func TestCompactionProjectsSummaryInputWithoutChangingPersistedTail(t *testing.T) {
298	client := &compactLifecycleClient{}
299	agent := NewAgent(client, "model", "system", Registry{})
300	oldText := strings.Repeat("full-compaction-result ", maxToolResultTextBytes)
301	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}
302	tailText := strings.Repeat("full-tail-result ", maxToolResultTextBytes)
303	agent.SetMessages([]provider.Message{
304		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "summarize this"}}},
305		{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "old-call", Name: "read"}}},
306		{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
307			CallID:  "old-call",
308			Content: []provider.Content{provider.TextBlock{Text: oldText}},
309			Timing:  timing,
310		}}},
311		{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "tail-call", Name: "read"}}},
312		{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
313			CallID:  "tail-call",
314			Content: []provider.Content{provider.TextBlock{Text: tailText}},
315		}}},
316	})
317	var persisted []provider.Message
318	agent.OnTranscriptCompacted = func(messages []provider.Message) {
319		persisted = messages
320	}
321
322	if _, err := agent.Compact(context.Background(), 2, nil); err != nil {
323		t.Fatalf("Compact: %v", err)
324	}
325	requestText := client.req.Messages[0].Content[0].(provider.TextBlock).Text
326	if !strings.Contains(requestText, toolResultOmissionMarker) {
327		t.Fatal("compaction request is missing the tool-result omission marker")
328	}
329	if !strings.Contains(requestText, formatToolTiming(timing)) {
330		t.Fatal("compaction request is missing the tool timing annotation")
331	}
332	if got := toolResultTextForCall(agent.Messages(), "tail-call"); got != tailText {
333		t.Fatalf("compacted Agent.Messages tail changed: got %d bytes, want %d", len(got), len(tailText))
334	}
335	if got := toolResultTextForCall(persisted, "tail-call"); got != tailText {
336		t.Fatalf("persisted compacted tail changed: got %d bytes, want %d", len(got), len(tailText))
337	}
338	if hasToolResultCall(agent.Messages(), "old-call") {
339		t.Fatal("summarized tool result remained in the compacted transcript")
340	}
341}
342
343func retainedToolResultTextBytes(content []provider.Content) int {
344	total := 0
345	for _, block := range content {
346		if text, ok := block.(provider.TextBlock); ok && text.Text != toolResultOmissionMarker {
347			total += len(text.Text)
348		}
349	}
350	return total
351}
352
353func hasToolResultOmissionMarker(content []provider.Content) bool {
354	for _, block := range content {
355		if text, ok := block.(provider.TextBlock); ok && text.Text == toolResultOmissionMarker {
356			return true
357		}
358	}
359	return false
360}
361
362func toolResultTextForCall(messages []provider.Message, callID string) string {
363	var text strings.Builder
364	for _, message := range messages {
365		for _, content := range message.Content {
366			result, ok := content.(provider.ToolResultBlock)
367			if !ok || result.CallID != callID {
368				continue
369			}
370			for _, inner := range result.Content {
371				if block, ok := inner.(provider.TextBlock); ok {
372					text.WriteString(block.Text)
373				}
374			}
375		}
376	}
377	return text.String()
378}
379
380func hasToolResultCall(messages []provider.Message, callID string) bool {
381	for _, message := range messages {
382		for _, content := range message.Content {
383			if result, ok := content.(provider.ToolResultBlock); ok && result.CallID == callID {
384				return true
385			}
386		}
387	}
388	return false
389}
390
391func min(a, b int) int {
392	if a < b {
393		return a
394	}
395	return b
396}

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