agent_test.go

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