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}