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}