agent_test.go

  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}