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}