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}