Files

741 lines
25 KiB
Go

package agent
import (
"fmt"
"testing"
)
// ===== Layer 1: Same-args loop detection =====
func TestToolLoopDetection_NoLoop(t *testing.T) {
var s toolLoopState
// 2 identical calls with same result → below threshold, no detection
for i := range 2 {
h := s.record("list_files", map[string]any{"path": "."})
s.recordResult(h, "access denied")
level, _ := s.detect("list_files", h)
if level != "" {
t.Fatalf("iteration %d: expected no detection, got %q", i, level)
}
}
}
func TestToolLoopDetection_Warning(t *testing.T) {
var s toolLoopState
var lastLevel string
for range toolLoopWarningThreshold {
h := s.record("list_files", map[string]any{"path": "."})
s.recordResult(h, "access denied")
lastLevel, _ = s.detect("list_files", h)
}
if lastLevel != "warning" {
t.Fatalf("expected warning after %d calls, got %q", toolLoopWarningThreshold, lastLevel)
}
}
func TestToolLoopDetection_Critical(t *testing.T) {
var s toolLoopState
var lastLevel string
for range toolLoopCriticalThreshold {
h := s.record("list_files", map[string]any{"path": "."})
s.recordResult(h, "access denied")
lastLevel, _ = s.detect("list_files", h)
}
if lastLevel != "critical" {
t.Fatalf("expected critical after %d calls, got %q", toolLoopCriticalThreshold, lastLevel)
}
}
func TestToolLoopDetection_DifferentArgs(t *testing.T) {
var s toolLoopState
// Same tool but different args each time → no detection
for i := range 15 {
args := map[string]any{"path": string(rune('a' + i))}
h := s.record("list_files", args)
s.recordResult(h, "access denied")
level, _ := s.detect("list_files", h)
if level != "" {
t.Fatalf("iteration %d: expected no detection for different args, got %q", i, level)
}
}
}
func TestToolLoopDetection_DifferentResults(t *testing.T) {
var s toolLoopState
// Same args but different results each time → progress, no detection
for i := range 15 {
h := s.record("web_fetch", map[string]any{"url": "https://example.com"})
s.recordResult(h, "result content "+string(rune('a'+i)))
level, _ := s.detect("web_fetch", h)
if level != "" {
t.Fatalf("iteration %d: expected no detection for different results, got %q", i, level)
}
}
}
func TestToolLoopDetection_MixedTools(t *testing.T) {
var s toolLoopState
// Alternate between two tools with same result → each tool only hit ~half
// With 8 iterations, each tool is called 4 times → below critical (5)
for i := range 8 {
toolName := "list_files"
if i%2 == 1 {
toolName = "read_file"
}
h := s.record(toolName, map[string]any{"path": "."})
s.recordResult(h, "error")
level, _ := s.detect(toolName, h)
// Each tool is only called 4 times, should at most warn
if level == "critical" {
t.Fatalf("iteration %d: unexpected critical for alternating tools", i)
}
}
}
func TestStableJSON(t *testing.T) {
// Same keys in different order → same hash
a := stableJSON(map[string]any{"b": 2, "a": 1})
b := stableJSON(map[string]any{"a": 1, "b": 2})
if a != b {
t.Fatalf("stableJSON not deterministic: %q != %q", a, b)
}
}
// ===== Group A: Uniqueness-aware read-only streak detection =====
func TestReadOnlyStreak_ExplorationMode_NoKill(t *testing.T) {
// A1: 12 unique reads ratio=1.0 exploration mode → NO trigger
var s toolLoopState
for i := range 12 {
s.recordMutation("read_file", map[string]any{"path": fmt.Sprintf("/file%d.txt", i)})
}
level, _ := s.detectReadOnlyStreak()
if level == "critical" {
t.Fatal("12 unique reads should NOT trigger critical in exploration mode")
}
// Should also not warn (exploration warning at 24)
if level != "" {
t.Fatalf("12 unique reads should not trigger any level, got %q", level)
}
}
func TestReadOnlyStreak_StuckMode_Kill(t *testing.T) {
// A2: Same file read 12 times → ratio=1/12 → stuck mode → KILL
var s toolLoopState
for range 12 {
s.recordMutation("read_file", map[string]any{"path": "/same-file.txt"})
}
level, _ := s.detectReadOnlyStreak()
if level != "critical" {
t.Fatalf("12 reads of same file should trigger critical, got %q", level)
}
}
func TestReadOnlyStreak_LowUniqueness_Kill(t *testing.T) {
// A3: 3 unique files read 4 times each (12 total) → ratio=0.25 → stuck → KILL
var s toolLoopState
files := []string{"/a.txt", "/b.txt", "/c.txt"}
for i := range 12 {
s.recordMutation("read_file", map[string]any{"path": files[i%3]})
}
level, _ := s.detectReadOnlyStreak()
if level != "critical" {
t.Fatalf("low uniqueness (3/12) should trigger critical, got %q", level)
}
}
func TestReadOnlyStreak_BoundaryRatio_Stuck(t *testing.T) {
// A4: 7 unique out of 12 = 0.583 → below 0.6 → stuck mode → critical at 12
var s toolLoopState
// First 7 unique
for i := range 7 {
s.recordMutation("read_file", map[string]any{"path": fmt.Sprintf("/unique%d.txt", i)})
}
// Next 5 repeat first file
for range 5 {
s.recordMutation("read_file", map[string]any{"path": "/unique0.txt"})
}
if s.readOnlyStreak != 12 {
t.Fatalf("expected streak=12, got %d", s.readOnlyStreak)
}
if s.readOnlyUnique != 7 {
t.Fatalf("expected unique=7, got %d", s.readOnlyUnique)
}
level, _ := s.detectReadOnlyStreak()
if level != "critical" {
t.Fatalf("7/12 unique (0.583) should be stuck mode → critical, got %q", level)
}
}
func TestReadOnlyStreak_BoundaryRatio_Exploration(t *testing.T) {
// A5: 8 unique out of 12 = 0.667 → above 0.6 → exploration mode → no trigger at 12
var s toolLoopState
// First 8 unique
for i := range 8 {
s.recordMutation("read_file", map[string]any{"path": fmt.Sprintf("/unique%d.txt", i)})
}
// Next 4 repeat first file
for range 4 {
s.recordMutation("read_file", map[string]any{"path": "/unique0.txt"})
}
if s.readOnlyStreak != 12 {
t.Fatalf("expected streak=12, got %d", s.readOnlyStreak)
}
if s.readOnlyUnique != 8 {
t.Fatalf("expected unique=8, got %d", s.readOnlyUnique)
}
level, _ := s.detectReadOnlyStreak()
if level != "" {
t.Fatalf("8/12 unique (0.667) should be exploration mode → no trigger at 12, got %q", level)
}
}
func TestReadOnlyStreak_ExplorationWarning(t *testing.T) {
// A6: 24 unique reads exploration mode → warning at 24
var s toolLoopState
for i := range 24 {
s.recordMutation("read_file", map[string]any{"path": fmt.Sprintf("/file%d.txt", i)})
}
level, _ := s.detectReadOnlyStreak()
if level != "warning" {
t.Fatalf("24 unique reads should trigger exploration warning, got %q", level)
}
}
func TestReadOnlyStreak_ExplorationKill(t *testing.T) {
// A7: 36 unique reads → exploration mode → critical at 36
var s toolLoopState
for i := range 36 {
s.recordMutation("read_file", map[string]any{"path": fmt.Sprintf("/file%d.txt", i)})
}
level, _ := s.detectReadOnlyStreak()
if level != "critical" {
t.Fatalf("36 unique reads should trigger exploration critical, got %q", level)
}
}
func TestReadOnlyStreak_MutationResetsUniqueness(t *testing.T) {
// A8: 10 unique reads → edit (resets) → 12 unique reads → exploration, no kill
var s toolLoopState
for i := range 10 {
s.recordMutation("read_file", map[string]any{"path": fmt.Sprintf("/file%d.txt", i)})
}
// Mutation resets everything
s.recordMutation("edit", nil)
if s.readOnlyStreak != 0 {
t.Fatalf("expected streak=0 after edit, got %d", s.readOnlyStreak)
}
if s.readOnlyUnique != 0 {
t.Fatalf("expected unique=0 after edit, got %d", s.readOnlyUnique)
}
if s.seenReadArgs != nil {
t.Fatal("expected seenReadArgs=nil after edit")
}
// 12 more unique reads → exploration mode
for i := range 12 {
s.recordMutation("read_file", map[string]any{"path": fmt.Sprintf("/new%d.txt", i)})
}
level, _ := s.detectReadOnlyStreak()
if level == "critical" {
t.Fatal("12 unique reads after mutation reset should NOT trigger critical")
}
}
func TestReadOnlyStreak_ZeroStreak(t *testing.T) {
// A9: Division-by-zero guard — fresh state no panic
var s toolLoopState
level, _ := s.detectReadOnlyStreak()
if level != "" {
t.Fatalf("expected no detection on zero streak, got %q", level)
}
}
// ===== Group B: Action-aware team_tasks =====
func TestReadOnlyStreak_TeamTasksProgressNeutral(t *testing.T) {
// B1: team_tasks(progress) is neutral — streak unchanged
var s toolLoopState
for range 5 {
s.recordMutation("read_file", map[string]any{"path": "/file.txt"})
}
if s.readOnlyStreak != 5 {
t.Fatalf("expected streak=5, got %d", s.readOnlyStreak)
}
s.recordMutation("team_tasks", map[string]any{"action": "progress", "percent": 50})
if s.readOnlyStreak != 5 {
t.Fatalf("team_tasks(progress) should be neutral, streak should stay 5, got %d", s.readOnlyStreak)
}
}
func TestReadOnlyStreak_TeamTasksListReadOnly(t *testing.T) {
// B2: team_tasks(list) is read-only — streak increments
var s toolLoopState
for range 5 {
s.recordMutation("read_file", map[string]any{"path": "/file.txt"})
}
s.recordMutation("team_tasks", map[string]any{"action": "list"})
if s.readOnlyStreak != 6 {
t.Fatalf("team_tasks(list) should increment streak to 6, got %d", s.readOnlyStreak)
}
}
func TestReadOnlyStreak_TeamTasksSearchReadOnly(t *testing.T) {
// B3: team_tasks(search) is read-only — streak increments
var s toolLoopState
for range 5 {
s.recordMutation("read_file", map[string]any{"path": "/file.txt"})
}
s.recordMutation("team_tasks", map[string]any{"action": "search", "query": "test"})
if s.readOnlyStreak != 6 {
t.Fatalf("team_tasks(search) should increment streak to 6, got %d", s.readOnlyStreak)
}
}
func TestReadOnlyStreak_TeamTasksGetReadOnly(t *testing.T) {
// B3b: team_tasks(get) is read-only — streak increments
var s toolLoopState
for range 5 {
s.recordMutation("read_file", map[string]any{"path": "/file.txt"})
}
s.recordMutation("team_tasks", map[string]any{"action": "get", "task_id": "abc"})
if s.readOnlyStreak != 6 {
t.Fatalf("team_tasks(get) should increment streak to 6, got %d", s.readOnlyStreak)
}
}
func TestReadOnlyStreak_TeamTasksCreateMutating(t *testing.T) {
// B4: team_tasks(create) is mutating — streak resets to 0
var s toolLoopState
for range 5 {
s.recordMutation("read_file", map[string]any{"path": "/file.txt"})
}
s.recordMutation("team_tasks", map[string]any{"action": "create", "subject": "test task"})
if s.readOnlyStreak != 0 {
t.Fatalf("team_tasks(create) should reset streak to 0, got %d", s.readOnlyStreak)
}
}
func TestReadOnlyStreak_TeamTasksCompleteMutating(t *testing.T) {
// B5: team_tasks(complete) is mutating — streak resets to 0
var s toolLoopState
for range 5 {
s.recordMutation("read_file", map[string]any{"path": "/file.txt"})
}
s.recordMutation("team_tasks", map[string]any{"action": "complete", "result": "done"})
if s.readOnlyStreak != 0 {
t.Fatalf("team_tasks(complete) should reset streak to 0, got %d", s.readOnlyStreak)
}
}
func TestReadOnlyStreak_TeamTasksCommentMutating(t *testing.T) {
// B6: team_tasks(comment) is mutating — streak resets to 0
var s toolLoopState
for range 5 {
s.recordMutation("read_file", map[string]any{"path": "/file.txt"})
}
s.recordMutation("team_tasks", map[string]any{"action": "comment", "text": "found something"})
if s.readOnlyStreak != 0 {
t.Fatalf("team_tasks(comment) should reset streak to 0, got %d", s.readOnlyStreak)
}
}
func TestReadOnlyStreak_TeamTasksNoAction(t *testing.T) {
// B7: team_tasks with no action arg → neutral (no crash)
var s toolLoopState
for range 5 {
s.recordMutation("read_file", map[string]any{"path": "/file.txt"})
}
// Empty args — should not panic
s.recordMutation("team_tasks", map[string]any{})
if s.readOnlyStreak != 5 {
t.Fatalf("team_tasks with no action should be neutral, streak should stay 5, got %d", s.readOnlyStreak)
}
// Nil args — should not panic
s.recordMutation("team_tasks", nil)
if s.readOnlyStreak != 5 {
t.Fatalf("team_tasks with nil args should be neutral, streak should stay 5, got %d", s.readOnlyStreak)
}
}
func TestReadOnlyStreak_TeamTasksUnknownAction(t *testing.T) {
// B8: Unknown action → mutating (safe default)
var s toolLoopState
for range 5 {
s.recordMutation("read_file", map[string]any{"path": "/file.txt"})
}
s.recordMutation("team_tasks", map[string]any{"action": "some_future_action"})
if s.readOnlyStreak != 0 {
t.Fatalf("team_tasks with unknown action should reset streak to 0, got %d", s.readOnlyStreak)
}
}
// ===== Group C: Trace replay (integration-style) =====
func TestReadOnlyStreak_TraceReplay_Issue506(t *testing.T) {
// C1: Exact trace scenario from issue #506.
// Agent explores monorepo: 12 unique reads + 1 team_tasks(progress).
// Should NOT trigger any critical kill.
var s toolLoopState
// Span 2: read_file(SKILL.md)
s.recordMutation("read_file", map[string]any{"path": "/app/data/skills-store/builder-test-fixer/1/SKILL.md"})
level, _ := s.detectReadOnlyStreak()
if level == "critical" {
t.Fatal("should not trigger critical at span 2")
}
// Span 4: team_tasks(progress, 10%)
s.recordMutation("team_tasks", map[string]any{"action": "progress", "percent": float64(10), "text": "started"})
if s.readOnlyStreak != 1 {
t.Fatalf("team_tasks(progress) should be neutral, streak should stay 1, got %d", s.readOnlyStreak)
}
// Span 6: read_file(plan.md)
s.recordMutation("read_file", map[string]any{"path": "/app/workspace/teams/019d2ad3/order-booking-optimization-plan-2026-03-26.md"})
// Span 7: read_file(qa-review.md)
s.recordMutation("read_file", map[string]any{"path": "/app/workspace/teams/019d2ad3/order-booking-phase-2-qa-review-2026-03-26.md"})
// Span 8: read_file(risk-review.md)
s.recordMutation("read_file", map[string]any{"path": "/app/workspace/teams/019d2ad3/order-booking-optimization-risk-review-2026-03-26.md"})
// Span 9: list_files(teams/...)
s.recordMutation("list_files", map[string]any{"path": "/app/workspace/teams/019d2ad3"})
// Span 10: list_files(.)
s.recordMutation("list_files", map[string]any{"path": "."})
// Span 12: list_files(order-booking/)
s.recordMutation("list_files", map[string]any{"path": "order-booking"})
// At this point: streak=8 (warning threshold in stuck mode, but exploration mode).
// Verify warning (if any) is not critical.
level, _ = s.detectReadOnlyStreak()
if level == "critical" {
t.Fatal("should not trigger critical at streak=8 in exploration mode")
}
// Span 13: read_file(api/package.json)
s.recordMutation("read_file", map[string]any{"path": "order-booking/apps/api/package.json"})
// Span 14: read_file(web/package.json)
s.recordMutation("read_file", map[string]any{"path": "order-booking/apps/web/package.json"})
// Span 15: read_file(package.json)
s.recordMutation("read_file", map[string]any{"path": "order-booking/package.json"})
// Span 16: read_file(database/package.json)
s.recordMutation("read_file", map[string]any{"path": "order-booking/packages/database/package.json"})
// Count: 1 (SKILL.md) + 0 (progress neutral) + 3 (plan/qa/risk) + 3 (list_files) + 4 (pkg.jsons) = 11 reads
if s.readOnlyStreak != 11 {
t.Fatalf("expected streak=11, got %d", s.readOnlyStreak)
}
if s.readOnlyUnique != 11 {
t.Fatalf("expected unique=11, got %d", s.readOnlyUnique)
}
level, _ = s.detectReadOnlyStreak()
if level == "critical" {
t.Fatal("REGRESSION: 11 unique reads should NOT trigger critical — this was the #506 false positive")
}
// Span 17: read_file(types/package.json)
s.recordMutation("read_file", map[string]any{"path": "order-booking/packages/types/package.json"})
// Span 18: read_file(turbo.json)
s.recordMutation("read_file", map[string]any{"path": "order-booking/turbo.json"})
// streak=13, all unique. In old code this would have been killed at 12.
// With uniqueness-aware detection, exploration mode continues.
if s.readOnlyStreak != 13 {
t.Fatalf("expected streak=13, got %d", s.readOnlyStreak)
}
level, _ = s.detectReadOnlyStreak()
if level == "critical" {
t.Fatal("13 unique reads should not trigger critical in exploration mode")
}
if level != "" {
t.Fatalf("13 unique reads in exploration mode should have no trigger, got %q", level)
}
}
func TestReadOnlyStreak_TraceReplay_StuckLoop(t *testing.T) {
// C2: Agent reads same 2 files alternating 12 times → stuck → KILL
var s toolLoopState
for i := range 12 {
file := "/a.txt"
if i%2 == 1 {
file = "/b.txt"
}
s.recordMutation("read_file", map[string]any{"path": file})
}
// ratio = 2/12 = 0.167 → stuck mode
if s.readOnlyUnique != 2 {
t.Fatalf("expected unique=2, got %d", s.readOnlyUnique)
}
level, _ := s.detectReadOnlyStreak()
if level != "critical" {
t.Fatalf("stuck loop (2 files alternating) should trigger critical at 12, got %q", level)
}
}
func TestReadOnlyStreak_ExactBoundaryRatio(t *testing.T) {
// E4: Exactly 0.6 ratio → stuck mode (strict greater-than check)
// 6 unique out of 10 = 0.6 → NOT > 0.6 → stuck mode → warning at 10
var s toolLoopState
for i := range 6 {
s.recordMutation("read_file", map[string]any{"path": fmt.Sprintf("/u%d.txt", i)})
}
for range 4 {
s.recordMutation("read_file", map[string]any{"path": "/u0.txt"})
}
if s.readOnlyStreak != 10 {
t.Fatalf("expected streak=10, got %d", s.readOnlyStreak)
}
if s.readOnlyUnique != 6 {
t.Fatalf("expected unique=6, got %d", s.readOnlyUnique)
}
level, _ := s.detectReadOnlyStreak()
// ratio=0.6, NOT > 0.6, so stuck mode. Streak=10 >= warning=8 → warning.
if level != "warning" {
t.Fatalf("exact 0.6 ratio should be stuck mode → warning at 10, got %q", level)
}
}
// ===== Existing tests — updated for new recordMutation(toolName, args) signature =====
func TestReadOnlyStreak_Warning(t *testing.T) {
var s toolLoopState
for range readOnlyStreakWarning {
s.recordMutation("read_file", nil)
}
level, _ := s.detectReadOnlyStreak()
// With nil args, all hashes are identical → unique=1 → ratio=1/8=0.125 → stuck mode
if level != "warning" {
t.Fatalf("expected warning after %d read-only calls, got %q", readOnlyStreakWarning, level)
}
}
func TestReadOnlyStreak_Critical(t *testing.T) {
var s toolLoopState
for range readOnlyStreakCritical {
s.recordMutation("list_files", nil)
}
level, _ := s.detectReadOnlyStreak()
// nil args → unique=1 → ratio=1/12=0.083 → stuck mode → critical
if level != "critical" {
t.Fatalf("expected critical after %d read-only calls, got %q", readOnlyStreakCritical, level)
}
}
func TestReadOnlyStreak_ResetByMutation(t *testing.T) {
var s toolLoopState
// 7 read-only calls → below warning
for range 7 {
s.recordMutation("read_file", nil)
}
// 1 edit resets streak
s.recordMutation("edit", nil)
if s.readOnlyStreak != 0 {
t.Fatalf("expected streak 0 after edit, got %d", s.readOnlyStreak)
}
// 7 more reads → streak = 7, still below warning
for range 7 {
s.recordMutation("read_file", nil)
}
level, _ := s.detectReadOnlyStreak()
if level != "" {
t.Fatalf("expected no detection at streak 7, got %q", level)
}
}
func TestReadOnlyStreak_ExecNeutral(t *testing.T) {
var s toolLoopState
// 5 reads → streak = 5
for range 5 {
s.recordMutation("read_file", nil)
}
// exec does not reset or increment
s.recordMutation("exec", nil)
if s.readOnlyStreak != 5 {
t.Fatalf("expected streak 5 after exec, got %d", s.readOnlyStreak)
}
// 5 more reads → streak = 10
for range 5 {
s.recordMutation("list_files", nil)
}
if s.readOnlyStreak != 10 {
t.Fatalf("expected streak 10, got %d", s.readOnlyStreak)
}
level, _ := s.detectReadOnlyStreak()
if level != "warning" {
t.Fatalf("expected warning at streak 10, got %q", level)
}
}
func TestReadOnlyStreak_WaitNeutral(t *testing.T) {
var s toolLoopState
for range 5 {
s.recordMutation("read_file", nil)
}
s.recordMutation("wait", map[string]any{"timeMs": 1000})
if s.readOnlyStreak != 5 {
t.Fatalf("expected streak 5 after wait, got %d", s.readOnlyStreak)
}
}
func TestReadOnlyStreak_MCPNeutral(t *testing.T) {
var s toolLoopState
// 5 reads → streak = 5
for range 5 {
s.recordMutation("read_file", nil)
}
// MCP tools should not reset or increment (same as exec)
s.recordMutation("mcp_gmail__query_gmail_emails", nil)
if s.readOnlyStreak != 5 {
t.Fatalf("expected streak 5 after mcp tool, got %d", s.readOnlyStreak)
}
s.recordMutation("mcp_gmail__get_gmail_email", nil)
if s.readOnlyStreak != 5 {
t.Fatalf("expected streak 5 after second mcp tool, got %d", s.readOnlyStreak)
}
// 7 more reads → streak = 12, should hit critical (stuck mode since nil args → unique=1)
for range 7 {
s.recordMutation("list_files", nil)
}
if s.readOnlyStreak != 12 {
t.Fatalf("expected streak 12, got %d", s.readOnlyStreak)
}
}
func TestReadOnlyStreak_MCPOnlyNeverTriggers(t *testing.T) {
var s toolLoopState
// 20 consecutive MCP tool calls → streak should stay 0
for range 20 {
s.recordMutation("mcp_gmail__query_gmail_emails", nil)
}
if s.readOnlyStreak != 0 {
t.Fatalf("expected streak 0 after 20 mcp-only calls, got %d", s.readOnlyStreak)
}
level, _ := s.detectReadOnlyStreak()
if level != "" {
t.Fatalf("expected no detection for mcp-only calls, got %q", level)
}
}
// ===== Layer 2: Same-result cross-args detection =====
func TestSameResult_Warning(t *testing.T) {
var s toolLoopState
sameResult := "directory listing output"
for i := range sameResultWarning {
args := map[string]any{"path": string(rune('a' + i))}
h := s.record("list_files", args)
s.recordResult(h, sameResult)
}
rh := hashResult(sameResult)
level, _ := s.detectSameResult("list_files", rh)
if level != "warning" {
t.Fatalf("expected warning after %d same-result calls, got %q", sameResultWarning, level)
}
}
func TestSameResult_Critical(t *testing.T) {
var s toolLoopState
sameResult := "directory listing output"
for i := range sameResultCritical {
args := map[string]any{"path": string(rune('a' + i))}
h := s.record("list_files", args)
s.recordResult(h, sameResult)
}
rh := hashResult(sameResult)
level, _ := s.detectSameResult("list_files", rh)
if level != "critical" {
t.Fatalf("expected critical after %d same-result calls, got %q", sameResultCritical, level)
}
}
func TestSameResult_DifferentResults(t *testing.T) {
var s toolLoopState
// Same tool, same args pattern, but different results each time → no detection
for i := range 8 {
args := map[string]any{"path": string(rune('a' + i))}
h := s.record("list_files", args)
s.recordResult(h, "result "+string(rune('a'+i)))
}
rh := hashResult("result a") // check against the first result
level, _ := s.detectSameResult("list_files", rh)
if level != "" {
t.Fatalf("expected no detection for different results, got %q", level)
}
}
func TestHashToolCall(t *testing.T) {
// Same input → same hash
h1 := hashToolCall("list_files", map[string]any{"path": "."})
h2 := hashToolCall("list_files", map[string]any{"path": "."})
if h1 != h2 {
t.Fatal("hashToolCall not deterministic")
}
// Different tool → different hash
h3 := hashToolCall("read_file", map[string]any{"path": "."})
if h1 == h3 {
t.Fatal("different tools should have different hashes")
}
}
// ===== Group D: Interaction between uniqueness and team_tasks =====
func TestReadOnlyStreak_ProgressBetweenReads(t *testing.T) {
// D1: reads → team_tasks(progress) → more reads → streak continues
var s toolLoopState
for i := range 5 {
s.recordMutation("read_file", map[string]any{"path": fmt.Sprintf("/file%d.txt", i)})
}
s.recordMutation("team_tasks", map[string]any{"action": "progress", "percent": 30})
// Streak should stay at 5 (progress is neutral)
if s.readOnlyStreak != 5 {
t.Fatalf("expected streak=5 after progress, got %d", s.readOnlyStreak)
}
for i := range 5 {
s.recordMutation("read_file", map[string]any{"path": fmt.Sprintf("/more%d.txt", i)})
}
// Streak=10, unique=10 → exploration mode, below warning
if s.readOnlyStreak != 10 {
t.Fatalf("expected streak=10, got %d", s.readOnlyStreak)
}
if s.readOnlyUnique != 10 {
t.Fatalf("expected unique=10, got %d", s.readOnlyUnique)
}
}
func TestReadOnlyStreak_ListBetweenReads(t *testing.T) {
// D2: reads → team_tasks(list) → more reads → streak continues (list is read-only)
var s toolLoopState
for i := range 5 {
s.recordMutation("read_file", map[string]any{"path": fmt.Sprintf("/file%d.txt", i)})
}
s.recordMutation("team_tasks", map[string]any{"action": "list"})
// team_tasks(list) is read-only → streak=6
if s.readOnlyStreak != 6 {
t.Fatalf("expected streak=6 after team_tasks(list), got %d", s.readOnlyStreak)
}
}
func TestReadOnlyStreak_CreateBetweenReads(t *testing.T) {
// D3: reads → team_tasks(create) → more reads → new streak from 0
var s toolLoopState
for i := range 5 {
s.recordMutation("read_file", map[string]any{"path": fmt.Sprintf("/file%d.txt", i)})
}
s.recordMutation("team_tasks", map[string]any{"action": "create", "subject": "task"})
if s.readOnlyStreak != 0 {
t.Fatalf("expected streak=0 after create, got %d", s.readOnlyStreak)
}
// New reads start fresh
for range 3 {
s.recordMutation("read_file", map[string]any{"path": "/new.txt"})
}
if s.readOnlyStreak != 3 {
t.Fatalf("expected streak=3, got %d", s.readOnlyStreak)
}
}