// Tests for the goal tools (mirrors pi-goal): goal_complete validation (empty and // contradictory summaries are rejected, a valid summary marks complete and // terminates the run), goal_blocked validation, and GoalState counter/lifecycle // behavior. Mirrors todo_tool_test.go's structure. package agenttool import ( "context" "encoding/json" "testing" "github.com/smallnest/pigo/internal/agentcore" ) // execGoalComplete runs the goal_complete tool with the given JSON args. func execGoalComplete(t *testing.T, tool *GoalCompleteTool, args string) agentcore.AgentToolResult { t.Helper() res, err := tool.Execute(context.Background(), "call-1", json.RawMessage(args), nil) if err != nil { t.Fatalf("Execute returned Go error: %v", err) } return res } func isErrorResult(res agentcore.AgentToolResult) bool { // An error result carries no Terminate and its text is the error message; // the tools return errorResult(...) which has Terminate=nil. return res.Terminate == nil } func TestGoalToolsRegister(t *testing.T) { reg := NewToolRegistry() st := NewGoalState() if err := reg.Register(&GoalCompleteTool{State: st}); err != nil { t.Fatalf("Register goal_complete: %v", err) } if err := reg.Register(&GoalBlockedTool{State: st}); err != nil { t.Fatalf("Register goal_blocked: %v", err) } if _, ok := reg.Get("goal_complete"); !ok { t.Fatal("goal_complete not found after Register") } if _, ok := reg.Get("goal_blocked"); !ok { t.Fatal("goal_blocked not found after Register") } } func TestGoalCompleteValidSummary(t *testing.T) { st := NewGoalState() st.Start("g1", "do the thing", 0) tool := &GoalCompleteTool{State: st} res := execGoalComplete(t, tool, `{"summary":"created hello.txt with the requested contents"}`) if res.Terminate == nil || !*res.Terminate { t.Fatalf("expected Terminate=true, got %v", res.Terminate) } if snap := st.Snapshot(); snap.Status != GoalComplete { t.Errorf("status = %q, want complete", snap.Status) } } func TestGoalCompleteRejectsEmpty(t *testing.T) { st := NewGoalState() st.Start("g1", "do the thing", 0) tool := &GoalCompleteTool{State: st} res := execGoalComplete(t, tool, `{"summary":" "}`) if !isErrorResult(res) { t.Fatal("expected error result for empty summary") } if snap := st.Snapshot(); snap.Status != GoalActive { t.Errorf("status = %q, want still active after rejected summary", snap.Status) } } func TestGoalCompleteRejectsContradictory(t *testing.T) { st := NewGoalState() st.Start("g1", "do the thing", 0) tool := &GoalCompleteTool{State: st} for _, summary := range []string{ `{"summary":"the goal is not complete yet"}`, `{"summary":"tests still fail but I stopped"}`, `{"summary":"the task is unfinished"}`, } { res := execGoalComplete(t, tool, summary) if !isErrorResult(res) { t.Fatalf("expected error result for contradictory summary %s", summary) } } if snap := st.Snapshot(); snap.Status != GoalActive { t.Errorf("status = %q, want still active", snap.Status) } } func TestGoalBlockedRecordsReason(t *testing.T) { st := NewGoalState() st.Start("g1", "do the thing", 0) tool := &GoalBlockedTool{State: st} res, err := tool.Execute(context.Background(), "c1", json.RawMessage(`{"reason":"missing API key","evidence":"env AUTH_TOKEN is empty"}`), nil) if err != nil { t.Fatalf("Execute: %v", err) } if res.Terminate == nil || !*res.Terminate { t.Fatalf("expected Terminate=true, got %v", res.Terminate) } snap := st.Snapshot() if snap.Status != GoalBlocked { t.Errorf("status = %q, want blocked", snap.Status) } if snap.BlockReason != "missing API key" { t.Errorf("block reason = %q", snap.BlockReason) } } func TestGoalBlockedRequiresEvidence(t *testing.T) { st := NewGoalState() st.Start("g1", "do the thing", 0) tool := &GoalBlockedTool{State: st} res, err := tool.Execute(context.Background(), "c1", json.RawMessage(`{"reason":"stuck","evidence":""}`), nil) if err != nil { t.Fatalf("Execute: %v", err) } if !isErrorResult(res) { t.Fatal("expected error result when evidence is empty") } if snap := st.Snapshot(); snap.Status != GoalActive { t.Errorf("status = %q, want still active", snap.Status) } } func TestGoalStateRecordIteration(t *testing.T) { st := NewGoalState() st.Start("g1", "obj", 1000) st.RecordIteration(100, true) // tool activity resets no-progress st.RecordIteration(50, false) // no tool activity st.RecordIteration(50, false) snap := st.Snapshot() if snap.Iterations != 3 { t.Errorf("iterations = %d, want 3", snap.Iterations) } if snap.TokensUsed != 200 { t.Errorf("tokensUsed = %d, want 200", snap.TokensUsed) } if snap.NoProgress != 2 { t.Errorf("noProgress = %d, want 2", snap.NoProgress) } if snap.TokenBudget != 1000 { t.Errorf("tokenBudget = %d, want 1000", snap.TokenBudget) } } func TestGoalStateClear(t *testing.T) { st := NewGoalState() st.Start("g1", "obj", 0) st.Clear() if snap := st.Snapshot(); snap.Status != GoalIdle || snap.Objective != "" { t.Errorf("after Clear: status=%q objective=%q, want idle/empty", snap.Status, snap.Objective) } } func TestGoalStateResume(t *testing.T) { st := NewGoalState() st.Start("g1", "obj", 1000) // Simulate a run that tripped a safety guard. st.RecordIteration(500, false) st.RecordIteration(500, false) st.SetStatus(GoalPaused) st.Resume() snap := st.Snapshot() if snap.Status != GoalActive { t.Errorf("status = %q, want active after Resume", snap.Status) } if snap.Iterations != 0 || snap.NoProgress != 0 || snap.TokensUsed != 0 { t.Errorf("Resume should clear transient counters: iterations=%d noProgress=%d tokensUsed=%d", snap.Iterations, snap.NoProgress, snap.TokensUsed) } if snap.Objective != "obj" || snap.TokenBudget != 1000 { t.Errorf("Resume should preserve objective/budget: objective=%q budget=%d", snap.Objective, snap.TokenBudget) } }