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}