1package agent
2
3import (
4 "fmt"
5 "testing"
6
7 "charm.land/fantasy"
8)
9
10// makeStep creates a StepResult with the given tool calls and results in its Content.
11func makeStep(calls []fantasy.ToolCallContent, results []fantasy.ToolResultContent) fantasy.StepResult {
12 var content fantasy.ResponseContent
13 for _, c := range calls {
14 content = append(content, c)
15 }
16 for _, r := range results {
17 content = append(content, r)
18 }
19 return fantasy.StepResult{
20 Response: fantasy.Response{
21 Content: content,
22 },
23 }
24}
25
26// makeToolStep creates a step with a single tool call and matching text result.
27func makeToolStep(name, input, output string) fantasy.StepResult {
28 callID := fmt.Sprintf("call_%s_%s", name, input)
29 return makeStep(
30 []fantasy.ToolCallContent{
31 {ToolCallID: callID, ToolName: name, Input: input},
32 },
33 []fantasy.ToolResultContent{
34 {ToolCallID: callID, ToolName: name, Result: fantasy.ToolResultOutputContentText{Text: output}},
35 },
36 )
37}
38
39// makeEmptyStep creates a step with no tool calls (e.g. a text-only response).
40func makeEmptyStep() fantasy.StepResult {
41 return fantasy.StepResult{
42 Response: fantasy.Response{
43 Content: fantasy.ResponseContent{
44 fantasy.TextContent{Text: "thinking..."},
45 },
46 },
47 }
48}
49
50func TestHasRepeatedToolCalls(t *testing.T) {
51 t.Run("no steps", func(t *testing.T) {
52 result := hasRepeatedToolCalls(nil, 10, 5)
53 if result {
54 t.Error("expected false for empty steps")
55 }
56 })
57
58 t.Run("fewer steps than window", func(t *testing.T) {
59 steps := make([]fantasy.StepResult, 5)
60 for i := range steps {
61 steps[i] = makeToolStep("read", `{"file":"a.go"}`, "content")
62 }
63 result := hasRepeatedToolCalls(steps, 10, 5)
64 if result {
65 t.Error("expected false when fewer steps than window size")
66 }
67 })
68
69 t.Run("all different signatures", func(t *testing.T) {
70 steps := make([]fantasy.StepResult, 10)
71 for i := range steps {
72 steps[i] = makeToolStep("tool", fmt.Sprintf(`{"i":%d}`, i), fmt.Sprintf("result-%d", i))
73 }
74 result := hasRepeatedToolCalls(steps, 10, 5)
75 if result {
76 t.Error("expected false when all signatures are different")
77 }
78 })
79
80 t.Run("exact repeat at threshold not detected", func(t *testing.T) {
81 // maxRepeats=5 means > 5 is needed, so exactly 5 should return false
82 steps := make([]fantasy.StepResult, 10)
83 for i := 0; i < 5; i++ {
84 steps[i] = makeToolStep("read", `{"file":"a.go"}`, "content")
85 }
86 for i := 5; i < 10; i++ {
87 steps[i] = makeToolStep("tool", fmt.Sprintf(`{"i":%d}`, i), fmt.Sprintf("result-%d", i))
88 }
89 result := hasRepeatedToolCalls(steps, 10, 5)
90 if result {
91 t.Error("expected false when count equals maxRepeats (threshold is >)")
92 }
93 })
94
95 t.Run("loop detected", func(t *testing.T) {
96 // 6 identical steps in a window of 10 with maxRepeats=5 → detected
97 steps := make([]fantasy.StepResult, 10)
98 for i := 0; i < 6; i++ {
99 steps[i] = makeToolStep("read", `{"file":"a.go"}`, "content")
100 }
101 for i := 6; i < 10; i++ {
102 steps[i] = makeToolStep("tool", fmt.Sprintf(`{"i":%d}`, i), fmt.Sprintf("result-%d", i))
103 }
104 result := hasRepeatedToolCalls(steps, 10, 5)
105 if !result {
106 t.Error("expected true when same signature appears more than maxRepeats times")
107 }
108 })
109
110 t.Run("steps without tool calls are skipped", func(t *testing.T) {
111 // Mix of tool steps and empty steps — empty ones should not affect counts
112 steps := make([]fantasy.StepResult, 10)
113 for i := 0; i < 4; i++ {
114 steps[i] = makeToolStep("read", `{"file":"a.go"}`, "content")
115 }
116 for i := 4; i < 8; i++ {
117 steps[i] = makeEmptyStep()
118 }
119 for i := 8; i < 10; i++ {
120 steps[i] = makeToolStep("write", `{"file":"b.go"}`, "ok")
121 }
122 result := hasRepeatedToolCalls(steps, 10, 5)
123 if result {
124 t.Error("expected false: only 4 repeated tool calls, empty steps should be skipped")
125 }
126 })
127
128 t.Run("multiple different patterns alternating", func(t *testing.T) {
129 // Two patterns alternating: each appears 5 times — not above threshold
130 steps := make([]fantasy.StepResult, 10)
131 for i := range steps {
132 if i%2 == 0 {
133 steps[i] = makeToolStep("read", `{"file":"a.go"}`, "content-a")
134 } else {
135 steps[i] = makeToolStep("write", `{"file":"b.go"}`, "content-b")
136 }
137 }
138 result := hasRepeatedToolCalls(steps, 10, 5)
139 if result {
140 t.Error("expected false: two patterns each appearing 5 times (not > 5)")
141 }
142 })
143}
144
145func TestGetToolInteractionSignature(t *testing.T) {
146 t.Run("empty content returns empty string", func(t *testing.T) {
147 sig := getToolInteractionSignature(fantasy.ResponseContent{})
148 if sig != "" {
149 t.Errorf("expected empty string, got %q", sig)
150 }
151 })
152
153 t.Run("text only content returns empty string", func(t *testing.T) {
154 content := fantasy.ResponseContent{
155 fantasy.TextContent{Text: "hello"},
156 }
157 sig := getToolInteractionSignature(content)
158 if sig != "" {
159 t.Errorf("expected empty string, got %q", sig)
160 }
161 })
162
163 t.Run("tool call with result produces signature", func(t *testing.T) {
164 content := fantasy.ResponseContent{
165 fantasy.ToolCallContent{ToolCallID: "1", ToolName: "read", Input: `{"file":"a.go"}`},
166 fantasy.ToolResultContent{ToolCallID: "1", ToolName: "read", Result: fantasy.ToolResultOutputContentText{Text: "content"}},
167 }
168 sig := getToolInteractionSignature(content)
169 if sig == "" {
170 t.Error("expected non-empty signature")
171 }
172 })
173
174 t.Run("same interactions produce same signature", func(t *testing.T) {
175 content1 := fantasy.ResponseContent{
176 fantasy.ToolCallContent{ToolCallID: "1", ToolName: "read", Input: `{"file":"a.go"}`},
177 fantasy.ToolResultContent{ToolCallID: "1", ToolName: "read", Result: fantasy.ToolResultOutputContentText{Text: "content"}},
178 }
179 content2 := fantasy.ResponseContent{
180 fantasy.ToolCallContent{ToolCallID: "2", ToolName: "read", Input: `{"file":"a.go"}`},
181 fantasy.ToolResultContent{ToolCallID: "2", ToolName: "read", Result: fantasy.ToolResultOutputContentText{Text: "content"}},
182 }
183 sig1 := getToolInteractionSignature(content1)
184 sig2 := getToolInteractionSignature(content2)
185 if sig1 != sig2 {
186 t.Errorf("expected same signature for same interactions, got %q and %q", sig1, sig2)
187 }
188 })
189
190 t.Run("different inputs produce different signatures", func(t *testing.T) {
191 content1 := fantasy.ResponseContent{
192 fantasy.ToolCallContent{ToolCallID: "1", ToolName: "read", Input: `{"file":"a.go"}`},
193 fantasy.ToolResultContent{ToolCallID: "1", ToolName: "read", Result: fantasy.ToolResultOutputContentText{Text: "content"}},
194 }
195 content2 := fantasy.ResponseContent{
196 fantasy.ToolCallContent{ToolCallID: "1", ToolName: "read", Input: `{"file":"b.go"}`},
197 fantasy.ToolResultContent{ToolCallID: "1", ToolName: "read", Result: fantasy.ToolResultOutputContentText{Text: "content"}},
198 }
199 sig1 := getToolInteractionSignature(content1)
200 sig2 := getToolInteractionSignature(content2)
201 if sig1 == sig2 {
202 t.Error("expected different signatures for different inputs")
203 }
204 })
205}