loop_detection_test.go

  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}