@@ -0,0 +1,205 @@
10+// makeStep creates a StepResult with the given tool calls and results in its Content.
11+func makeStep(calls []fantasy.ToolCallContent, results []fantasy.ToolResultContent) fantasy.StepResult {
12+ var content fantasy.ResponseContent
13+ for _, c := range calls {
14+ content = append(content, c)
16+ for _, r := range results {
17+ content = append(content, r)
19+ return fantasy.StepResult{
20+ Response: fantasy.Response{
26+// makeToolStep creates a step with a single tool call and matching text result.
27+func makeToolStep(name, input, output string) fantasy.StepResult {
28+ callID := fmt.Sprintf("call_%s_%s", name, input)
30+ []fantasy.ToolCallContent{
31+ {ToolCallID: callID, ToolName: name, Input: input},
33+ []fantasy.ToolResultContent{
34+ {ToolCallID: callID, ToolName: name, Result: fantasy.ToolResultOutputContentText{Text: output}},
39+// makeEmptyStep creates a step with no tool calls (e.g. a text-only response).
40+func makeEmptyStep() fantasy.StepResult {
41+ return fantasy.StepResult{
42+ Response: fantasy.Response{
43+ Content: fantasy.ResponseContent{
44+ fantasy.TextContent{Text: "thinking..."},
50+func TestHasRepeatedToolCalls(t *testing.T) {
51+ t.Run("no steps", func(t *testing.T) {
52+ result := hasRepeatedToolCalls(nil, 10, 5)
54+ t.Error("expected false for empty steps")
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")
63+ result := hasRepeatedToolCalls(steps, 10, 5)
65+ t.Error("expected false when fewer steps than window size")
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))
74+ result := hasRepeatedToolCalls(steps, 10, 5)
76+ t.Error("expected false when all signatures are different")
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")
86+ for i := 5; i < 10; i++ {
87+ steps[i] = makeToolStep("tool", fmt.Sprintf(`{"i":%d}`, i), fmt.Sprintf("result-%d", i))
89+ result := hasRepeatedToolCalls(steps, 10, 5)
91+ t.Error("expected false when count equals maxRepeats (threshold is >)")
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")
101+ for i := 6; i < 10; i++ {
102+ steps[i] = makeToolStep("tool", fmt.Sprintf(`{"i":%d}`, i), fmt.Sprintf("result-%d", i))
104+ result := hasRepeatedToolCalls(steps, 10, 5)
106+ t.Error("expected true when same signature appears more than maxRepeats times")
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")
116+ for i := 4; i < 8; i++ {
117+ steps[i] = makeEmptyStep()
119+ for i := 8; i < 10; i++ {
120+ steps[i] = makeToolStep("write", `{"file":"b.go"}`, "ok")
122+ result := hasRepeatedToolCalls(steps, 10, 5)
124+ t.Error("expected false: only 4 repeated tool calls, empty steps should be skipped")
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 {
133+ steps[i] = makeToolStep("read", `{"file":"a.go"}`, "content-a")
135+ steps[i] = makeToolStep("write", `{"file":"b.go"}`, "content-b")
138+ result := hasRepeatedToolCalls(steps, 10, 5)
140+ t.Error("expected false: two patterns each appearing 5 times (not > 5)")
145+func TestGetToolInteractionSignature(t *testing.T) {
146+ t.Run("empty content returns empty string", func(t *testing.T) {
147+ sig := getToolInteractionSignature(fantasy.ResponseContent{})
149+ t.Errorf("expected empty string, got %q", sig)
153+ t.Run("text only content returns empty string", func(t *testing.T) {
154+ content := fantasy.ResponseContent{
155+ fantasy.TextContent{Text: "hello"},
157+ sig := getToolInteractionSignature(content)
159+ t.Errorf("expected empty string, got %q", sig)
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"}},
168+ sig := getToolInteractionSignature(content)
170+ t.Error("expected non-empty signature")
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"}},
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"}},
183+ sig1 := getToolInteractionSignature(content1)
184+ sig2 := getToolInteractionSignature(content2)
186+ t.Errorf("expected same signature for same interactions, got %q and %q", sig1, sig2)
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"}},
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"}},
199+ sig1 := getToolInteractionSignature(content1)
200+ sig2 := getToolInteractionSignature(content2)
202+ t.Error("expected different signatures for different inputs")