mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-09-02 22:17:42 +00:00
741 lines
25 KiB
Go
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)
|
|
}
|
|
}
|