1package agent
2
3import (
4 "fmt"
5 "log/slog"
6 "os"
7 "path/filepath"
8 "runtime"
9 "strings"
10 "testing"
11 "time"
12
13 "charm.land/fantasy"
14 "charm.land/x/vcr"
15 "github.com/charmbracelet/crush/internal/agent/tools"
16 "github.com/charmbracelet/crush/internal/message"
17 "github.com/charmbracelet/crush/internal/session"
18 "github.com/stretchr/testify/assert"
19 "github.com/stretchr/testify/require"
20
21 _ "github.com/joho/godotenv/autoload"
22)
23
24func TestMain(m *testing.M) {
25 slog.SetLogLoggerLevel(slog.LevelError)
26 m.Run()
27}
28
29var modelPairs = []modelPair{
30 {"glm-5.1", hyperBuilder("glm-5.1"), hyperBuilder("gpt-oss-120b")},
31}
32
33func getModels(t *testing.T, r *vcr.Recorder, pair modelPair) (fantasy.LanguageModel, fantasy.LanguageModel) {
34 large, err := pair.largeModel(t, r)
35 require.NoError(t, err)
36 small, err := pair.smallModel(t, r)
37 require.NoError(t, err)
38 return large, small
39}
40
41func setupAgent(t *testing.T, pair modelPair) (SessionAgent, fakeEnv) {
42 r := vcr.NewRecorder(t)
43 large, small := getModels(t, r, pair)
44 env := testEnv(t)
45
46 createSimpleGoProject(t, env.workingDir)
47 agent, err := coderAgent(r, env, large, small)
48 require.NoError(t, err)
49 return agent, env
50}
51
52func TestCoderAgent(t *testing.T) {
53 if runtime.GOOS == "windows" {
54 t.Skip("skipping on windows for now")
55 }
56
57 for _, pair := range modelPairs {
58 t.Run(pair.name, func(t *testing.T) {
59 t.Run("simple test", func(t *testing.T) {
60 agent, env := setupAgent(t, pair)
61
62 session, err := env.sessions.Create(t.Context(), "New Session")
63 require.NoError(t, err)
64
65 res, err := agent.Run(t.Context(), SessionAgentCall{
66 Prompt: "Hello",
67 SessionID: session.ID,
68 MaxOutputTokens: 10000,
69 })
70 require.NoError(t, err)
71 assert.NotNil(t, res)
72
73 msgs, err := env.messages.List(t.Context(), session.ID)
74 require.NoError(t, err)
75 // Should have the agent and user message
76 assert.Equal(t, len(msgs), 2)
77 })
78 t.Run("read a file", func(t *testing.T) {
79 agent, env := setupAgent(t, pair)
80
81 session, err := env.sessions.Create(t.Context(), "New Session")
82 require.NoError(t, err)
83 res, err := agent.Run(t.Context(), SessionAgentCall{
84 Prompt: "Read the go mod",
85 SessionID: session.ID,
86 MaxOutputTokens: 10000,
87 })
88
89 require.NoError(t, err)
90 assert.NotNil(t, res)
91
92 msgs, err := env.messages.List(t.Context(), session.ID)
93 require.NoError(t, err)
94 foundFile := false
95 var tcID string
96 out:
97 for _, msg := range msgs {
98 if msg.Role == message.Assistant {
99 for _, tc := range msg.ToolCalls() {
100 if tc.Name == tools.ViewToolName {
101 tcID = tc.ID
102 }
103 }
104 }
105 if msg.Role == message.Tool {
106 for _, tr := range msg.ToolResults() {
107 if tr.ToolCallID == tcID {
108 if strings.Contains(tr.Content, "module example.com/testproject") {
109 foundFile = true
110 break out
111 }
112 }
113 }
114 }
115 }
116 require.True(t, foundFile)
117 })
118 t.Run("update a file", func(t *testing.T) {
119 agent, env := setupAgent(t, pair)
120
121 session, err := env.sessions.Create(t.Context(), "New Session")
122 require.NoError(t, err)
123
124 res, err := agent.Run(t.Context(), SessionAgentCall{
125 Prompt: "update the main.go file by changing the print to say hello from crush",
126 SessionID: session.ID,
127 MaxOutputTokens: 10000,
128 })
129 require.NoError(t, err)
130 assert.NotNil(t, res)
131
132 msgs, err := env.messages.List(t.Context(), session.ID)
133 require.NoError(t, err)
134
135 foundRead := false
136 foundWrite := false
137 var readTCID, writeTCID string
138
139 for _, msg := range msgs {
140 if msg.Role == message.Assistant {
141 for _, tc := range msg.ToolCalls() {
142 if tc.Name == tools.ViewToolName {
143 readTCID = tc.ID
144 }
145 if tc.Name == tools.EditToolName || tc.Name == tools.WriteToolName {
146 writeTCID = tc.ID
147 }
148 }
149 }
150 if msg.Role == message.Tool {
151 for _, tr := range msg.ToolResults() {
152 if tr.ToolCallID == readTCID {
153 foundRead = true
154 }
155 if tr.ToolCallID == writeTCID {
156 foundWrite = true
157 }
158 }
159 }
160 }
161
162 require.True(t, foundRead, "Expected to find a read operation")
163 require.True(t, foundWrite, "Expected to find a write operation")
164
165 mainGoPath := filepath.Join(env.workingDir, "main.go")
166 content, err := os.ReadFile(mainGoPath)
167 require.NoError(t, err)
168 require.Contains(t, strings.ToLower(string(content)), "hello from crush")
169 })
170 t.Run("bash tool", func(t *testing.T) {
171 agent, env := setupAgent(t, pair)
172
173 session, err := env.sessions.Create(t.Context(), "New Session")
174 require.NoError(t, err)
175
176 res, err := agent.Run(t.Context(), SessionAgentCall{
177 Prompt: "use bash to create a file named test.txt with content 'hello bash'. do not print its timestamp",
178 SessionID: session.ID,
179 MaxOutputTokens: 10000,
180 })
181 require.NoError(t, err)
182 assert.NotNil(t, res)
183
184 msgs, err := env.messages.List(t.Context(), session.ID)
185 require.NoError(t, err)
186
187 foundBash := false
188 var bashTCID string
189
190 for _, msg := range msgs {
191 if msg.Role == message.Assistant {
192 for _, tc := range msg.ToolCalls() {
193 if tc.Name == tools.BashToolName {
194 bashTCID = tc.ID
195 }
196 }
197 }
198 if msg.Role == message.Tool {
199 for _, tr := range msg.ToolResults() {
200 if tr.ToolCallID == bashTCID {
201 foundBash = true
202 }
203 }
204 }
205 }
206
207 require.True(t, foundBash, "Expected to find a bash operation")
208
209 testFilePath := filepath.Join(env.workingDir, "test.txt")
210 content, err := os.ReadFile(testFilePath)
211 require.NoError(t, err)
212 require.Contains(t, string(content), "hello bash")
213 })
214 t.Run("download tool", func(t *testing.T) {
215 agent, env := setupAgent(t, pair)
216
217 session, err := env.sessions.Create(t.Context(), "New Session")
218 require.NoError(t, err)
219
220 res, err := agent.Run(t.Context(), SessionAgentCall{
221 Prompt: "download the file from https://example-files.online-convert.com/document/txt/example.txt and save it as example.txt",
222 SessionID: session.ID,
223 MaxOutputTokens: 10000,
224 })
225 require.NoError(t, err)
226 assert.NotNil(t, res)
227
228 msgs, err := env.messages.List(t.Context(), session.ID)
229 require.NoError(t, err)
230
231 foundDownload := false
232 var downloadTCID string
233
234 for _, msg := range msgs {
235 if msg.Role == message.Assistant {
236 for _, tc := range msg.ToolCalls() {
237 if tc.Name == tools.DownloadToolName {
238 downloadTCID = tc.ID
239 }
240 }
241 }
242 if msg.Role == message.Tool {
243 for _, tr := range msg.ToolResults() {
244 if tr.ToolCallID == downloadTCID {
245 foundDownload = true
246 }
247 }
248 }
249 }
250
251 require.True(t, foundDownload, "Expected to find a download operation")
252
253 examplePath := filepath.Join(env.workingDir, "example.txt")
254 _, err = os.Stat(examplePath)
255 require.NoError(t, err, "Expected example.txt file to exist")
256 })
257 t.Run("fetch tool", func(t *testing.T) {
258 agent, env := setupAgent(t, pair)
259
260 session, err := env.sessions.Create(t.Context(), "New Session")
261 require.NoError(t, err)
262
263 res, err := agent.Run(t.Context(), SessionAgentCall{
264 Prompt: "fetch the content from https://example-files.online-convert.com/website/html/example.html and tell me if it contains the word 'John Doe'",
265 SessionID: session.ID,
266 MaxOutputTokens: 10000,
267 })
268 require.NoError(t, err)
269 assert.NotNil(t, res)
270
271 msgs, err := env.messages.List(t.Context(), session.ID)
272 require.NoError(t, err)
273
274 foundFetch := false
275 var fetchTCID string
276
277 for _, msg := range msgs {
278 if msg.Role == message.Assistant {
279 for _, tc := range msg.ToolCalls() {
280 if tc.Name == tools.FetchToolName {
281 fetchTCID = tc.ID
282 }
283 }
284 }
285 if msg.Role == message.Tool {
286 for _, tr := range msg.ToolResults() {
287 if tr.ToolCallID == fetchTCID {
288 foundFetch = true
289 }
290 }
291 }
292 }
293
294 require.True(t, foundFetch, "Expected to find a fetch operation")
295 })
296 t.Run("glob tool", func(t *testing.T) {
297 agent, env := setupAgent(t, pair)
298
299 session, err := env.sessions.Create(t.Context(), "New Session")
300 require.NoError(t, err)
301
302 res, err := agent.Run(t.Context(), SessionAgentCall{
303 Prompt: "use glob to find all .go files in the current directory",
304 SessionID: session.ID,
305 MaxOutputTokens: 10000,
306 })
307 require.NoError(t, err)
308 assert.NotNil(t, res)
309
310 msgs, err := env.messages.List(t.Context(), session.ID)
311 require.NoError(t, err)
312
313 foundGlob := false
314 var globTCID string
315
316 for _, msg := range msgs {
317 if msg.Role == message.Assistant {
318 for _, tc := range msg.ToolCalls() {
319 if tc.Name == tools.GlobToolName {
320 globTCID = tc.ID
321 }
322 }
323 }
324 if msg.Role == message.Tool {
325 for _, tr := range msg.ToolResults() {
326 if tr.ToolCallID == globTCID {
327 foundGlob = true
328 require.Contains(t, tr.Content, "main.go", "Expected glob to find main.go")
329 }
330 }
331 }
332 }
333
334 require.True(t, foundGlob, "Expected to find a glob operation")
335 })
336 t.Run("grep tool", func(t *testing.T) {
337 agent, env := setupAgent(t, pair)
338
339 session, err := env.sessions.Create(t.Context(), "New Session")
340 require.NoError(t, err)
341
342 res, err := agent.Run(t.Context(), SessionAgentCall{
343 Prompt: "use grep to search for the word 'package' in go files",
344 SessionID: session.ID,
345 MaxOutputTokens: 10000,
346 })
347 require.NoError(t, err)
348 assert.NotNil(t, res)
349
350 msgs, err := env.messages.List(t.Context(), session.ID)
351 require.NoError(t, err)
352
353 foundGrep := false
354 var grepTCID string
355
356 for _, msg := range msgs {
357 if msg.Role == message.Assistant {
358 for _, tc := range msg.ToolCalls() {
359 if tc.Name == tools.GrepToolName {
360 grepTCID = tc.ID
361 }
362 }
363 }
364 if msg.Role == message.Tool {
365 for _, tr := range msg.ToolResults() {
366 if tr.ToolCallID == grepTCID {
367 foundGrep = true
368 require.Contains(t, tr.Content, "main.go", "Expected grep to find main.go")
369 }
370 }
371 }
372 }
373
374 require.True(t, foundGrep, "Expected to find a grep operation")
375 })
376 t.Run("ls tool", func(t *testing.T) {
377 agent, env := setupAgent(t, pair)
378
379 session, err := env.sessions.Create(t.Context(), "New Session")
380 require.NoError(t, err)
381
382 res, err := agent.Run(t.Context(), SessionAgentCall{
383 Prompt: "use ls to list the files in the current directory",
384 SessionID: session.ID,
385 MaxOutputTokens: 10000,
386 })
387 require.NoError(t, err)
388 assert.NotNil(t, res)
389
390 msgs, err := env.messages.List(t.Context(), session.ID)
391 require.NoError(t, err)
392
393 foundLS := false
394 var lsTCID string
395
396 for _, msg := range msgs {
397 if msg.Role == message.Assistant {
398 for _, tc := range msg.ToolCalls() {
399 if tc.Name == tools.LSToolName {
400 lsTCID = tc.ID
401 }
402 }
403 }
404 if msg.Role == message.Tool {
405 for _, tr := range msg.ToolResults() {
406 if tr.ToolCallID == lsTCID {
407 foundLS = true
408 require.Contains(t, tr.Content, "main.go", "Expected ls to list main.go")
409 require.Contains(t, tr.Content, "go.mod", "Expected ls to list go.mod")
410 }
411 }
412 }
413 }
414
415 require.True(t, foundLS, "Expected to find an ls operation")
416 })
417 t.Run("multiedit tool", func(t *testing.T) {
418 agent, env := setupAgent(t, pair)
419
420 session, err := env.sessions.Create(t.Context(), "New Session")
421 require.NoError(t, err)
422
423 res, err := agent.Run(t.Context(), SessionAgentCall{
424 Prompt: "use multiedit to change 'Hello, World!' to 'Hello, Crush!' and add a comment '// Greeting' above the fmt.Println line in main.go",
425 SessionID: session.ID,
426 MaxOutputTokens: 10000,
427 })
428 require.NoError(t, err)
429 assert.NotNil(t, res)
430
431 msgs, err := env.messages.List(t.Context(), session.ID)
432 require.NoError(t, err)
433
434 foundMultiEdit := false
435 var multiEditTCID string
436
437 for _, msg := range msgs {
438 if msg.Role == message.Assistant {
439 for _, tc := range msg.ToolCalls() {
440 if tc.Name == tools.MultiEditToolName {
441 multiEditTCID = tc.ID
442 }
443 }
444 }
445 if msg.Role == message.Tool {
446 for _, tr := range msg.ToolResults() {
447 if tr.ToolCallID == multiEditTCID {
448 foundMultiEdit = true
449 }
450 }
451 }
452 }
453
454 require.True(t, foundMultiEdit, "Expected to find a multiedit operation")
455
456 mainGoPath := filepath.Join(env.workingDir, "main.go")
457 content, err := os.ReadFile(mainGoPath)
458 require.NoError(t, err)
459 require.Contains(t, string(content), "Hello, Crush!", "Expected file to contain 'Hello, Crush!'")
460 })
461 t.Run("sourcegraph tool", func(t *testing.T) {
462 agent, env := setupAgent(t, pair)
463
464 session, err := env.sessions.Create(t.Context(), "New Session")
465 require.NoError(t, err)
466
467 res, err := agent.Run(t.Context(), SessionAgentCall{
468 Prompt: "use sourcegraph to search for 'func main' in Go repositories",
469 SessionID: session.ID,
470 MaxOutputTokens: 10000,
471 })
472 require.NoError(t, err)
473 assert.NotNil(t, res)
474
475 msgs, err := env.messages.List(t.Context(), session.ID)
476 require.NoError(t, err)
477
478 foundSourcegraph := false
479 var sourcegraphTCID string
480
481 for _, msg := range msgs {
482 if msg.Role == message.Assistant {
483 for _, tc := range msg.ToolCalls() {
484 if tc.Name == tools.SourcegraphToolName {
485 sourcegraphTCID = tc.ID
486 }
487 }
488 }
489 if msg.Role == message.Tool {
490 for _, tr := range msg.ToolResults() {
491 if tr.ToolCallID == sourcegraphTCID {
492 foundSourcegraph = true
493 }
494 }
495 }
496 }
497
498 require.True(t, foundSourcegraph, "Expected to find a sourcegraph operation")
499 })
500 t.Run("write tool", func(t *testing.T) {
501 agent, env := setupAgent(t, pair)
502
503 session, err := env.sessions.Create(t.Context(), "New Session")
504 require.NoError(t, err)
505
506 res, err := agent.Run(t.Context(), SessionAgentCall{
507 Prompt: "use write to create a new file called config.json with content '{\"name\": \"test\", \"version\": \"1.0.0\"}'",
508 SessionID: session.ID,
509 MaxOutputTokens: 10000,
510 })
511 require.NoError(t, err)
512 assert.NotNil(t, res)
513
514 msgs, err := env.messages.List(t.Context(), session.ID)
515 require.NoError(t, err)
516
517 foundWrite := false
518 var writeTCID string
519
520 for _, msg := range msgs {
521 if msg.Role == message.Assistant {
522 for _, tc := range msg.ToolCalls() {
523 if tc.Name == tools.WriteToolName {
524 writeTCID = tc.ID
525 }
526 }
527 }
528 if msg.Role == message.Tool {
529 for _, tr := range msg.ToolResults() {
530 if tr.ToolCallID == writeTCID {
531 foundWrite = true
532 }
533 }
534 }
535 }
536
537 require.True(t, foundWrite, "Expected to find a write operation")
538
539 configPath := filepath.Join(env.workingDir, "config.json")
540 content, err := os.ReadFile(configPath)
541 require.NoError(t, err)
542 require.Contains(t, string(content), "test", "Expected config.json to contain 'test'")
543 require.Contains(t, string(content), "1.0.0", "Expected config.json to contain '1.0.0'")
544 })
545 t.Run("parallel tool calls", func(t *testing.T) {
546 agent, env := setupAgent(t, pair)
547
548 session, err := env.sessions.Create(t.Context(), "New Session")
549 require.NoError(t, err)
550
551 res, err := agent.Run(t.Context(), SessionAgentCall{
552 Prompt: "use glob to find all .go files and use ls to list the current directory, it is very important that you run both tool calls in parallel",
553 SessionID: session.ID,
554 MaxOutputTokens: 10000,
555 })
556 require.NoError(t, err)
557 assert.NotNil(t, res)
558
559 msgs, err := env.messages.List(t.Context(), session.ID)
560 require.NoError(t, err)
561
562 var assistantMsg *message.Message
563 var toolMsgs []message.Message
564
565 for _, msg := range msgs {
566 if msg.Role == message.Assistant && len(msg.ToolCalls()) > 0 {
567 assistantMsg = &msg
568 }
569 if msg.Role == message.Tool {
570 toolMsgs = append(toolMsgs, msg)
571 }
572 }
573
574 require.NotNil(t, assistantMsg, "Expected to find an assistant message with tool calls")
575 require.NotNil(t, toolMsgs, "Expected to find a tool message")
576
577 toolCalls := assistantMsg.ToolCalls()
578 require.GreaterOrEqual(t, len(toolCalls), 2, "Expected at least 2 tool calls in parallel")
579
580 foundGlob := false
581 foundLS := false
582 var globTCID, lsTCID string
583
584 for _, tc := range toolCalls {
585 if tc.Name == tools.GlobToolName {
586 foundGlob = true
587 globTCID = tc.ID
588 }
589 if tc.Name == tools.LSToolName {
590 foundLS = true
591 lsTCID = tc.ID
592 }
593 }
594
595 require.True(t, foundGlob, "Expected to find a glob tool call")
596 require.True(t, foundLS, "Expected to find an ls tool call")
597
598 require.GreaterOrEqual(t, len(toolMsgs), 2, "Expected at least 2 tool results in the same message")
599
600 foundGlobResult := false
601 foundLSResult := false
602
603 for _, msg := range toolMsgs {
604 for _, tr := range msg.ToolResults() {
605 if tr.ToolCallID == globTCID {
606 foundGlobResult = true
607 require.Contains(t, tr.Content, "main.go", "Expected glob result to contain main.go")
608 require.False(t, tr.IsError, "Expected glob result to not be an error")
609 }
610 if tr.ToolCallID == lsTCID {
611 foundLSResult = true
612 require.Contains(t, tr.Content, "main.go", "Expected ls result to contain main.go")
613 require.False(t, tr.IsError, "Expected ls result to not be an error")
614 }
615 }
616 }
617
618 require.True(t, foundGlobResult, "Expected to find glob tool result")
619 require.True(t, foundLSResult, "Expected to find ls tool result")
620 })
621 })
622 }
623}
624
625func makeTestTodos(n int) []session.Todo {
626 todos := make([]session.Todo, n)
627 for i := range n {
628 todos[i] = session.Todo{
629 Status: session.TodoStatusPending,
630 Content: fmt.Sprintf("Task %d: Implement feature with some description that makes it realistic", i),
631 }
632 }
633 return todos
634}
635
636func BenchmarkBuildSummaryPrompt(b *testing.B) {
637 cases := []struct {
638 name string
639 numTodos int
640 }{
641 {"0todos", 0},
642 {"5todos", 5},
643 {"10todos", 10},
644 {"50todos", 50},
645 }
646
647 for _, tc := range cases {
648 todos := makeTestTodos(tc.numTodos)
649
650 b.Run(tc.name, func(b *testing.B) {
651 b.ReportAllocs()
652 for range b.N {
653 _ = buildSummaryPrompt(todos)
654 }
655 })
656 }
657}
658
659func TestPreparePrompt_OrphanedToolUse(t *testing.T) {
660 env := testEnv(t)
661 sa := testSessionAgent(env, nil, nil, "test prompt")
662 agent := sa.(*sessionAgent)
663
664 ctx := t.Context()
665 sess, err := env.sessions.Create(ctx, "test")
666 require.NoError(t, err)
667
668 // Create a user message.
669 _, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
670 Role: message.User,
671 Parts: []message.ContentPart{
672 message.TextContent{Text: "hello"},
673 },
674 })
675 require.NoError(t, err)
676
677 // Create an assistant message with a tool call but no tool result —
678 // this simulates a cancelled/interrupted agent tool call.
679 _, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
680 Role: message.Assistant,
681 Parts: []message.ContentPart{
682 message.TextContent{Text: "let me check"},
683 message.ToolCall{
684 ID: "call_orphaned_1",
685 Name: "agent",
686 Input: `{"prompt":"do something"}`,
687 Finished: true,
688 },
689 },
690 })
691 require.NoError(t, err)
692
693 // Create the next user message (the one that interrupted the tool call).
694 _, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
695 Role: message.User,
696 Parts: []message.ContentPart{
697 message.TextContent{Text: "Fix #2"},
698 },
699 })
700 require.NoError(t, err)
701
702 msgs, err := env.messages.List(ctx, sess.ID)
703 require.NoError(t, err)
704
705 history, _ := agent.preparePrompt(msgs)
706
707 // The history must contain a synthetic tool result for the orphaned call.
708 found := false
709 for _, msg := range history {
710 if msg.Role != fantasy.MessageRoleTool {
711 continue
712 }
713 for _, part := range msg.Content {
714 if tr, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part); ok {
715 if tr.ToolCallID == "call_orphaned_1" {
716 found = true
717 _, isError := tr.Output.(fantasy.ToolResultOutputContentError)
718 require.True(t, isError, "orphaned tool result should be an error")
719 }
720 }
721 }
722 }
723 require.True(t, found, "expected synthetic tool result for orphaned tool call")
724}
725
726func TestPreparePrompt_OrphanedToolUseMixed(t *testing.T) {
727 env := testEnv(t)
728 sa := testSessionAgent(env, nil, nil, "test prompt")
729 agent := sa.(*sessionAgent)
730
731 ctx := t.Context()
732 sess, err := env.sessions.Create(ctx, "test")
733 require.NoError(t, err)
734
735 _, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
736 Role: message.User,
737 Parts: []message.ContentPart{
738 message.TextContent{Text: "hello"},
739 },
740 })
741 require.NoError(t, err)
742
743 // Assistant with 2 tool calls: one has a result, one is orphaned.
744 _, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
745 Role: message.Assistant,
746 Parts: []message.ContentPart{
747 message.ToolCall{
748 ID: "call_ok",
749 Name: "view",
750 Input: `{"path":"/foo"}`,
751 Finished: true,
752 },
753 message.ToolCall{
754 ID: "call_orphaned",
755 Name: "agent",
756 Input: `{"prompt":"search"}`,
757 Finished: true,
758 },
759 },
760 })
761 require.NoError(t, err)
762
763 // Only one tool result — for call_ok.
764 _, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
765 Role: message.Tool,
766 Parts: []message.ContentPart{
767 message.ToolResult{
768 ToolCallID: "call_ok",
769 Name: "view",
770 Content: "file contents",
771 },
772 },
773 })
774 require.NoError(t, err)
775
776 msgs, err := env.messages.List(ctx, sess.ID)
777 require.NoError(t, err)
778
779 history, _ := agent.preparePrompt(msgs)
780
781 // Should have a synthetic result only for the orphaned call.
782 var syntheticCount int
783 for _, msg := range history {
784 if msg.Role != fantasy.MessageRoleTool {
785 continue
786 }
787 for _, part := range msg.Content {
788 if tr, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part); ok {
789 if tr.ToolCallID == "call_orphaned" {
790 syntheticCount++
791 }
792 }
793 }
794 }
795 require.Equal(t, 1, syntheticCount, "expected exactly one synthetic result for the orphaned call")
796}
797
798func TestProviderRetryLogFields(t *testing.T) {
799 t.Run("nil provider error", func(t *testing.T) {
800 fields := providerRetryLogFields(nil, 2*time.Second)
801 require.Equal(t, []any{"retry_delay", "2s"}, fields)
802 })
803
804 t.Run("provider error with title and message", func(t *testing.T) {
805 fields := providerRetryLogFields(&fantasy.ProviderError{
806 StatusCode: 429,
807 Title: "rate limit",
808 Message: "too many requests",
809 }, 1500*time.Millisecond)
810 require.Equal(t, []any{
811 "retry_delay", "1.5s",
812 "status_code", 429,
813 "title", "rate limit",
814 "message", "too many requests",
815 }, fields)
816 })
817
818 t.Run("provider error without optional strings", func(t *testing.T) {
819 fields := providerRetryLogFields(&fantasy.ProviderError{
820 StatusCode: 503,
821 }, time.Second)
822 require.Equal(t, []any{
823 "retry_delay", "1s",
824 "status_code", 503,
825 }, fields)
826 })
827}