| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205 |
- package agent
- import (
- "fmt"
- "testing"
- "charm.land/fantasy"
- )
- // makeStep creates a StepResult with the given tool calls and results in its Content.
- func makeStep(calls []fantasy.ToolCallContent, results []fantasy.ToolResultContent) fantasy.StepResult {
- var content fantasy.ResponseContent
- for _, c := range calls {
- content = append(content, c)
- }
- for _, r := range results {
- content = append(content, r)
- }
- return fantasy.StepResult{
- Response: fantasy.Response{
- Content: content,
- },
- }
- }
- // makeToolStep creates a step with a single tool call and matching text result.
- func makeToolStep(name, input, output string) fantasy.StepResult {
- callID := fmt.Sprintf("call_%s_%s", name, input)
- return makeStep(
- []fantasy.ToolCallContent{
- {ToolCallID: callID, ToolName: name, Input: input},
- },
- []fantasy.ToolResultContent{
- {ToolCallID: callID, ToolName: name, Result: fantasy.ToolResultOutputContentText{Text: output}},
- },
- )
- }
- // makeEmptyStep creates a step with no tool calls (e.g. a text-only response).
- func makeEmptyStep() fantasy.StepResult {
- return fantasy.StepResult{
- Response: fantasy.Response{
- Content: fantasy.ResponseContent{
- fantasy.TextContent{Text: "thinking..."},
- },
- },
- }
- }
- func TestHasRepeatedToolCalls(t *testing.T) {
- t.Run("no steps", func(t *testing.T) {
- result := hasRepeatedToolCalls(nil, 10, 5)
- if result {
- t.Error("expected false for empty steps")
- }
- })
- t.Run("fewer steps than window", func(t *testing.T) {
- steps := make([]fantasy.StepResult, 5)
- for i := range steps {
- steps[i] = makeToolStep("read", `{"file":"a.go"}`, "content")
- }
- result := hasRepeatedToolCalls(steps, 10, 5)
- if result {
- t.Error("expected false when fewer steps than window size")
- }
- })
- t.Run("all different signatures", func(t *testing.T) {
- steps := make([]fantasy.StepResult, 10)
- for i := range steps {
- steps[i] = makeToolStep("tool", fmt.Sprintf(`{"i":%d}`, i), fmt.Sprintf("result-%d", i))
- }
- result := hasRepeatedToolCalls(steps, 10, 5)
- if result {
- t.Error("expected false when all signatures are different")
- }
- })
- t.Run("exact repeat at threshold not detected", func(t *testing.T) {
- // maxRepeats=5 means > 5 is needed, so exactly 5 should return false
- steps := make([]fantasy.StepResult, 10)
- for i := 0; i < 5; i++ {
- steps[i] = makeToolStep("read", `{"file":"a.go"}`, "content")
- }
- for i := 5; i < 10; i++ {
- steps[i] = makeToolStep("tool", fmt.Sprintf(`{"i":%d}`, i), fmt.Sprintf("result-%d", i))
- }
- result := hasRepeatedToolCalls(steps, 10, 5)
- if result {
- t.Error("expected false when count equals maxRepeats (threshold is >)")
- }
- })
- t.Run("loop detected", func(t *testing.T) {
- // 6 identical steps in a window of 10 with maxRepeats=5 → detected
- steps := make([]fantasy.StepResult, 10)
- for i := 0; i < 6; i++ {
- steps[i] = makeToolStep("read", `{"file":"a.go"}`, "content")
- }
- for i := 6; i < 10; i++ {
- steps[i] = makeToolStep("tool", fmt.Sprintf(`{"i":%d}`, i), fmt.Sprintf("result-%d", i))
- }
- result := hasRepeatedToolCalls(steps, 10, 5)
- if !result {
- t.Error("expected true when same signature appears more than maxRepeats times")
- }
- })
- t.Run("steps without tool calls are skipped", func(t *testing.T) {
- // Mix of tool steps and empty steps — empty ones should not affect counts
- steps := make([]fantasy.StepResult, 10)
- for i := 0; i < 4; i++ {
- steps[i] = makeToolStep("read", `{"file":"a.go"}`, "content")
- }
- for i := 4; i < 8; i++ {
- steps[i] = makeEmptyStep()
- }
- for i := 8; i < 10; i++ {
- steps[i] = makeToolStep("write", `{"file":"b.go"}`, "ok")
- }
- result := hasRepeatedToolCalls(steps, 10, 5)
- if result {
- t.Error("expected false: only 4 repeated tool calls, empty steps should be skipped")
- }
- })
- t.Run("multiple different patterns alternating", func(t *testing.T) {
- // Two patterns alternating: each appears 5 times — not above threshold
- steps := make([]fantasy.StepResult, 10)
- for i := range steps {
- if i%2 == 0 {
- steps[i] = makeToolStep("read", `{"file":"a.go"}`, "content-a")
- } else {
- steps[i] = makeToolStep("write", `{"file":"b.go"}`, "content-b")
- }
- }
- result := hasRepeatedToolCalls(steps, 10, 5)
- if result {
- t.Error("expected false: two patterns each appearing 5 times (not > 5)")
- }
- })
- }
- func TestGetToolInteractionSignature(t *testing.T) {
- t.Run("empty content returns empty string", func(t *testing.T) {
- sig := getToolInteractionSignature(fantasy.ResponseContent{})
- if sig != "" {
- t.Errorf("expected empty string, got %q", sig)
- }
- })
- t.Run("text only content returns empty string", func(t *testing.T) {
- content := fantasy.ResponseContent{
- fantasy.TextContent{Text: "hello"},
- }
- sig := getToolInteractionSignature(content)
- if sig != "" {
- t.Errorf("expected empty string, got %q", sig)
- }
- })
- t.Run("tool call with result produces signature", func(t *testing.T) {
- content := fantasy.ResponseContent{
- fantasy.ToolCallContent{ToolCallID: "1", ToolName: "read", Input: `{"file":"a.go"}`},
- fantasy.ToolResultContent{ToolCallID: "1", ToolName: "read", Result: fantasy.ToolResultOutputContentText{Text: "content"}},
- }
- sig := getToolInteractionSignature(content)
- if sig == "" {
- t.Error("expected non-empty signature")
- }
- })
- t.Run("same interactions produce same signature", func(t *testing.T) {
- content1 := fantasy.ResponseContent{
- fantasy.ToolCallContent{ToolCallID: "1", ToolName: "read", Input: `{"file":"a.go"}`},
- fantasy.ToolResultContent{ToolCallID: "1", ToolName: "read", Result: fantasy.ToolResultOutputContentText{Text: "content"}},
- }
- content2 := fantasy.ResponseContent{
- fantasy.ToolCallContent{ToolCallID: "2", ToolName: "read", Input: `{"file":"a.go"}`},
- fantasy.ToolResultContent{ToolCallID: "2", ToolName: "read", Result: fantasy.ToolResultOutputContentText{Text: "content"}},
- }
- sig1 := getToolInteractionSignature(content1)
- sig2 := getToolInteractionSignature(content2)
- if sig1 != sig2 {
- t.Errorf("expected same signature for same interactions, got %q and %q", sig1, sig2)
- }
- })
- t.Run("different inputs produce different signatures", func(t *testing.T) {
- content1 := fantasy.ResponseContent{
- fantasy.ToolCallContent{ToolCallID: "1", ToolName: "read", Input: `{"file":"a.go"}`},
- fantasy.ToolResultContent{ToolCallID: "1", ToolName: "read", Result: fantasy.ToolResultOutputContentText{Text: "content"}},
- }
- content2 := fantasy.ResponseContent{
- fantasy.ToolCallContent{ToolCallID: "1", ToolName: "read", Input: `{"file":"b.go"}`},
- fantasy.ToolResultContent{ToolCallID: "1", ToolName: "read", Result: fantasy.ToolResultOutputContentText{Text: "content"}},
- }
- sig1 := getToolInteractionSignature(content1)
- sig2 := getToolInteractionSignature(content2)
- if sig1 == sig2 {
- t.Error("expected different signatures for different inputs")
- }
- })
- }
|