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