| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | |
| 12 | "reasonix/internal/agent" |
| 13 | "reasonix/internal/event" |
| 14 | "reasonix/internal/evidence" |
| 15 | "reasonix/internal/goaleval" |
| 16 | "reasonix/internal/provider" |
| 17 | "reasonix/internal/store" |
| 18 | "reasonix/internal/tool" |
| 19 | ) |
| 20 | |
| 21 | // goalRuntimeController wires a controller whose goal turns carry no |
| 22 | // update_goal report, so the bounded evaluator decides every disposition. It |
| 23 | // returns the TurnDone/Notice channel for waiting. |
| 24 | func goalRuntimeController(t *testing.T, prov provider.Provider, eval goaleval.Evaluator) (*Controller, *agent.Agent, <-chan event.Event) { |
| 25 | t.Helper() |
| 26 | ag := agent.New(prov, goalRegistry(), agent.NewSession(""), agent.Options{}, event.Discard) |
| 27 | events := make(chan event.Event, 8) |
| 28 | c := New(Options{ |
| 29 | Runner: ag, |
| 30 | Executor: ag, |
| 31 | GoalEvaluator: eval, |
| 32 | Sink: event.FuncSink(func(e event.Event) { |
| 33 | if e.Kind == event.TurnDone || e.Kind == event.Notice { |
| 34 | events <- e |
| 35 | } |
| 36 | }), |
| 37 | }) |
| 38 | return c, ag, events |
| 39 | } |
| 40 | |
| 41 | // waitGoalTurnDone drains notices until the goal loop's TurnDone. |
| 42 | func waitGoalTurnDone(t *testing.T, events <-chan event.Event) { |
| 43 | t.Helper() |
| 44 | for e := range events { |
| 45 | if e.Kind == event.TurnDone { |
| 46 | return |
| 47 | } |
| 48 | } |
| 49 | t.Fatal("goal loop ended without TurnDone") |
| 50 | } |
| 51 | |
| 52 | // TestSimpleGoalWithoutReportCompletesViaEvaluator pins the acceptance |
| 53 | // criterion: a simple Q&A goal whose model never calls update_goal still ends |
| 54 | // on the first turn when the evaluator says complete. |
| 55 | func TestSimpleGoalWithoutReportCompletesViaEvaluator(t *testing.T) { |
| 56 | prov := &scriptedTurns{turns: [][]provider.Chunk{textTurn("Here is the answer.")}} |
| 57 | c, _, events := goalRuntimeController(t, prov, &fakeGoalEvaluator{outcome: goaleval.OutcomeComplete, reason: "the question is fully answered"}) |
| 58 | |
| 59 | c.Submit("/goal explain the cache behavior") |
| 60 | waitGoalTurnDone(t, events) |
| 61 | |
| 62 | if prov.call != 1 { |
| 63 | t.Fatalf("provider calls = %d, want 1 (evaluator decides on the first turn, no second round)", prov.call) |
| 64 | } |
| 65 | if got := c.GoalStatus(); got != GoalStatusComplete { |
| 66 | t.Fatalf("GoalStatus() = %q, want complete", got) |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | func TestGoalEvaluatorUsageCommitsBeforeFSMCompletion(t *testing.T) { |
| 71 | sink := NewGoalUsageTee(event.Discard) |
| 72 | mainProv := &scriptedTurns{turns: [][]provider.Chunk{textTurn("Here is the answer.")}} |
| 73 | evalProv := &scriptedTurns{turns: [][]provider.Chunk{{ |
| 74 | {Type: provider.ChunkText, Text: `{"outcome":"complete","reason":"done"}`}, |
| 75 | {Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: 60, CompletionTokens: 17, TotalTokens: 77}}, |
| 76 | {Type: provider.ChunkDone}, |
| 77 | }}} |
| 78 | executor := agent.New(mainProv, goalRegistry(), agent.NewSession(""), agent.Options{}, sink) |
| 79 | evaluator := goaleval.NewSessionWithSink(evalProv, nil, "test/evaluator", sink) |
| 80 | c := New(Options{Runner: executor, Executor: executor, GoalEvaluator: evaluator, Sink: sink}) |
| 81 | c.SetGoal("answer once") |
| 82 | if err := newTurnOrchestrator(c).runGoalLoopWithRawDisplay(context.Background(), "answer", "answer", ""); err != nil { |
| 83 | t.Fatal(err) |
| 84 | } |
| 85 | if c.GoalStatus() != GoalStatusComplete { |
| 86 | t.Fatalf("status = %q, want complete", c.GoalStatus()) |
| 87 | } |
| 88 | if got := c.GoalRuntime().TokensUsed; got != 77 { |
| 89 | t.Fatalf("evaluator usage = %d, want 77 committed before FSM completion", got) |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | // TestEvaluatorOutcomesDriveFSM covers the evaluator verdict matrix. |
| 94 | func TestEvaluatorOutcomesDriveFSM(t *testing.T) { |
| 95 | cases := []struct { |
| 96 | name string |
| 97 | outcome goaleval.Outcome |
| 98 | wantStatus string |
| 99 | wantCause string |
| 100 | }{ |
| 101 | {"complete", goaleval.OutcomeComplete, GoalStatusComplete, ""}, |
| 102 | {"continue", goaleval.OutcomeContinue, GoalStatusRunning, ""}, |
| 103 | {"blocked", goaleval.OutcomeBlocked, GoalStatusBlocked, ""}, |
| 104 | {"uncertain fails closed", goaleval.OutcomeUncertain, GoalStatusBlocked, stopCauseEvaluator}, |
| 105 | } |
| 106 | for _, tc := range cases { |
| 107 | t.Run(tc.name, func(t *testing.T) { |
| 108 | prov := &scriptedTurns{turns: [][]provider.Chunk{textTurn("done.")}} |
| 109 | c, _, events := goalRuntimeController(t, prov, &fakeGoalEvaluator{outcome: tc.outcome, reason: "verdict"}) |
| 110 | c.Submit("/goal assess the impact") |
| 111 | waitGoalTurnDone(t, events) |
| 112 | if tc.wantStatus == GoalStatusRunning { |
| 113 | // continue keeps the loop going; without host-verifiable |
| 114 | // progress it eventually pauses on the no-progress gate rather |
| 115 | // than stopping at turn 1. |
| 116 | if got := c.GoalStatus(); got != GoalStatusBlocked { |
| 117 | t.Fatalf("GoalStatus() = %q, want the loop to keep going until a pause", got) |
| 118 | } |
| 119 | if rt := c.GoalRuntime(); rt.StopCause != stopCauseNoProgress || rt.TurnsUsed < 2 { |
| 120 | t.Fatalf("runtime = %+v, want no-progress pause after multiple turns", rt) |
| 121 | } |
| 122 | return |
| 123 | } |
| 124 | if got := c.GoalStatus(); got != tc.wantStatus { |
| 125 | t.Fatalf("GoalStatus() = %q, want %q", got, tc.wantStatus) |
| 126 | } |
| 127 | if rt := c.GoalRuntime(); rt.StopCause != tc.wantCause { |
| 128 | t.Fatalf("StopCause = %q, want %q", rt.StopCause, tc.wantCause) |
| 129 | } |
| 130 | }) |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | // TestEvaluatorErrorPausesFirstTurn pins fail-closed: an erroring evaluator |
| 135 | // pauses the goal on the first turn without looping to a fixed cap. |
| 136 | func TestEvaluatorErrorPausesFirstTurn(t *testing.T) { |
| 137 | prov := &scriptedTurns{turns: [][]provider.Chunk{textTurn("done.")}} |
| 138 | c, _, events := goalRuntimeController(t, prov, &fakeGoalEvaluator{err: context.DeadlineExceeded}) |
| 139 | c.Submit("/goal evaluate this") |
| 140 | waitGoalTurnDone(t, events) |
| 141 | if got := c.GoalStatus(); got != GoalStatusBlocked { |
| 142 | t.Fatalf("GoalStatus() = %q, want blocked (fail closed)", got) |
| 143 | } |
| 144 | if rt := c.GoalRuntime(); rt.StopCause != stopCauseEvaluator || rt.TurnsUsed != 1 { |
| 145 | t.Fatalf("runtime = %+v, want evaluator pause after 1 turn", rt) |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | // TestEvaluatorUnavailablePausesFirstTurn pins the no-evaluator configuration. |
| 150 | func TestEvaluatorUnavailablePausesFirstTurn(t *testing.T) { |
| 151 | prov := &scriptedTurns{turns: [][]provider.Chunk{textTurn("done.")}} |
| 152 | c, _, events := goalRuntimeController(t, prov, nil) |
| 153 | c.Submit("/goal evaluate this") |
| 154 | waitGoalTurnDone(t, events) |
| 155 | if got := c.GoalStatus(); got != GoalStatusBlocked { |
| 156 | t.Fatalf("GoalStatus() = %q, want blocked (evaluator unavailable)", got) |
| 157 | } |
| 158 | if rt := c.GoalRuntime(); rt.StopCause != stopCauseEvaluator { |
| 159 | t.Fatalf("StopCause = %q, want %q", rt.StopCause, stopCauseEvaluator) |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | // TestEvaluatorCompleteStillGatedByReadiness: the evaluator's complete claim |
| 164 | // must pass host readiness — seeded incomplete todos keep the goal going. |
| 165 | func TestEvaluatorCompleteStillGatedByReadiness(t *testing.T) { |
| 166 | prov := &scriptedTurns{turns: [][]provider.Chunk{ |
| 167 | textTurn("done."), |
| 168 | textTurn("done again."), |
| 169 | }} |
| 170 | ag := agent.New(prov, goalRegistry(), agent.NewSession(""), agent.Options{}, event.Discard) |
| 171 | ag.SeedTodoState([]evidence.TodoItem{{Content: "Fix the parser", Status: "in_progress"}}) |
| 172 | done := make(chan event.Event, 1) |
| 173 | c := New(Options{ |
| 174 | Runner: ag, |
| 175 | Executor: ag, |
| 176 | GoalEvaluator: &fakeGoalEvaluator{outcome: goaleval.OutcomeComplete, reason: "all done"}, |
| 177 | Sink: event.FuncSink(func(e event.Event) { |
| 178 | if e.Kind == event.TurnDone { |
| 179 | done <- e |
| 180 | } |
| 181 | }), |
| 182 | }) |
| 183 | c.Submit("/goal fix everything") |
| 184 | <-done |
| 185 | // The evaluator's complete claim is rejected (incomplete todos) and the |
| 186 | // goal continues; without host-verifiable progress the no-progress gate |
| 187 | // pauses instead of looping forever. |
| 188 | if got := c.GoalStatus(); got != GoalStatusBlocked { |
| 189 | t.Fatalf("GoalStatus() = %q, want blocked (no-progress after rejected complete)", got) |
| 190 | } |
| 191 | if rt := c.GoalRuntime(); rt.StopCause != stopCauseNoProgress { |
| 192 | t.Fatalf("StopCause = %q, want %q", rt.StopCause, stopCauseNoProgress) |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | // TestTurnTokenNoProgressPausesAndResumeExtendsBudget covers turn and |
| 197 | // no-progress budgets at the FSM level and the resume extension contract. |
| 198 | // Token hard limits no longer pause goals. |
| 199 | func TestTurnTokenNoProgressPausesAndResumeExtendsBudget(t *testing.T) { |
| 200 | newMachine := func() *goalMachine { |
| 201 | g := &goalMachine{goal: "fix the parser", status: GoalStatusRunning} |
| 202 | g.budgetClass = budgetClassWrite |
| 203 | g.turnsLimit = budgetQuota(budgetClassWrite) |
| 204 | g.tokensLimit = 0 |
| 205 | g.noProgressLimit = defaultNoProgressLimit |
| 206 | return g |
| 207 | } |
| 208 | in := func(report *goalTurnReport, before, after string) goalAdvanceInput { |
| 209 | return goalAdvanceInput{report: report, progressBefore: before, progressAfter: after} |
| 210 | } |
| 211 | |
| 212 | t.Run("turn budget pauses", func(t *testing.T) { |
| 213 | g := newMachine() |
| 214 | for i := 0; i < g.turnsLimit; i++ { |
| 215 | // Progress changes every turn so only the turn budget can fire. |
| 216 | res := g.advance(in(&goalTurnReport{status: GoalStatusRunning, reason: "keep going"}, "s", "s"+fmt.Sprint(i))) |
| 217 | if res.cont && i == g.turnsLimit-1 { |
| 218 | t.Fatal("last turn must pause") |
| 219 | } |
| 220 | } |
| 221 | if g.status != GoalStatusBlocked || g.stopCause != stopCauseBudgetTurns { |
| 222 | t.Fatalf("machine = (%q, %q), want blocked+budget_turns", g.status, g.stopCause) |
| 223 | } |
| 224 | }) |
| 225 | |
| 226 | t.Run("token usage never pauses", func(t *testing.T) { |
| 227 | g := newMachine() |
| 228 | // Even a huge observational total must not stop the goal. |
| 229 | g.tokensUsed = 900_000 |
| 230 | res := g.advance(in(&goalTurnReport{status: GoalStatusRunning, reason: "keep going"}, "s", "s2")) |
| 231 | if !res.cont { |
| 232 | t.Fatal("token usage must not pause the goal") |
| 233 | } |
| 234 | if g.stopCause != "" { |
| 235 | t.Fatalf("stop cause = %q, want empty", g.stopCause) |
| 236 | } |
| 237 | }) |
| 238 | |
| 239 | t.Run("no-progress pauses", func(t *testing.T) { |
| 240 | g := newMachine() |
| 241 | for i := 0; i < g.noProgressLimit; i++ { |
| 242 | g.advance(in(&goalTurnReport{status: GoalStatusRunning, reason: "still working"}, "sig", "sig")) |
| 243 | } |
| 244 | if g.status != GoalStatusBlocked || g.stopCause != stopCauseNoProgress { |
| 245 | t.Fatalf("machine = (%q, %q), want blocked+no_progress", g.status, g.stopCause) |
| 246 | } |
| 247 | }) |
| 248 | |
| 249 | t.Run("host-verifiable progress resets no-progress", func(t *testing.T) { |
| 250 | g := newMachine() |
| 251 | for i := 0; i < g.noProgressLimit-1; i++ { |
| 252 | g.advance(in(&goalTurnReport{status: GoalStatusRunning, reason: "still working"}, "sig", "sig")) |
| 253 | } |
| 254 | res := g.advance(in(&goalTurnReport{status: GoalStatusRunning, reason: "progress!"}, "sig", "sig2")) |
| 255 | if !res.cont { |
| 256 | t.Fatalf("progress must reset the stall counter: %+v", g) |
| 257 | } |
| 258 | if g.noProgressTurns != 0 { |
| 259 | t.Fatalf("noProgressTurns = %d, want 0", g.noProgressTurns) |
| 260 | } |
| 261 | }) |
| 262 | |
| 263 | t.Run("resume extends turn budget once", func(t *testing.T) { |
| 264 | g := newMachine() |
| 265 | for i := 0; i < g.turnsLimit; i++ { |
| 266 | g.advance(in(&goalTurnReport{status: GoalStatusRunning, reason: "keep going"}, "s", "s")) |
| 267 | } |
| 268 | beforeLimit := g.turnsLimit |
| 269 | _, _, _, resumed, extended := g.resume(nil) |
| 270 | if !resumed || !extended { |
| 271 | t.Fatalf("resume = (%v, %v), want (true, true)", resumed, extended) |
| 272 | } |
| 273 | if g.turnsLimit != beforeLimit+budgetQuota(budgetClassWrite) { |
| 274 | t.Fatalf("turnsLimit = %d, want %d", g.turnsLimit, beforeLimit+budgetQuota(budgetClassWrite)) |
| 275 | } |
| 276 | if g.tokensLimit != 0 { |
| 277 | t.Fatalf("tokensLimit = %d, want 0 (no hard token ceiling)", g.tokensLimit) |
| 278 | } |
| 279 | if g.budgetExtensions != 1 || g.stopCause != "" || g.noProgressTurns != 0 { |
| 280 | t.Fatalf("machine after resume = %+v", g) |
| 281 | } |
| 282 | }) |
| 283 | |
| 284 | t.Run("manual pause resume does not extend", func(t *testing.T) { |
| 285 | g := newMachine() |
| 286 | g.pauseFor(stopCauseManual, "user paused", nil) |
| 287 | beforeLimit := g.turnsLimit |
| 288 | _, _, _, resumed, extended := g.resume(nil) |
| 289 | if !resumed || extended { |
| 290 | t.Fatalf("resume = (%v, %v), want (true, false)", resumed, extended) |
| 291 | } |
| 292 | if g.turnsLimit != beforeLimit { |
| 293 | t.Fatalf("turnsLimit changed on manual resume: %d", g.turnsLimit) |
| 294 | } |
| 295 | }) |
| 296 | } |
| 297 | |
| 298 | // TestGoalTurnRecorderProtocol covers idempotency, upgrades, terminal |
| 299 | // conflicts, and stale-epoch rejection. |
| 300 | func TestGoalTurnRecorderProtocol(t *testing.T) { |
| 301 | newRec := func(t *testing.T) (*goalMachine, *goalTurnRecorder) { |
| 302 | t.Helper() |
| 303 | g := &goalMachine{goal: "fix it", status: GoalStatusRunning} |
| 304 | g.scopeID = newGoalScopeID() |
| 305 | rec := g.newTurnRecorder(g.scopeID, g.continuationEpoch) |
| 306 | return g, rec |
| 307 | } |
| 308 | report := func(status, reason string) tool.GoalReport { |
| 309 | return tool.GoalReport{Status: status, Reason: reason, NextAction: ""} |
| 310 | } |
| 311 | |
| 312 | t.Run("idempotent same value", func(t *testing.T) { |
| 313 | _, rec := newRec(t) |
| 314 | if _, err := rec.RecordGoalReport(report(GoalStatusRunning, "working")); err != nil { |
| 315 | t.Fatal(err) |
| 316 | } |
| 317 | if _, err := rec.RecordGoalReport(report(GoalStatusRunning, "working")); err != nil { |
| 318 | t.Fatalf("identical repeat must be idempotent: %v", err) |
| 319 | } |
| 320 | if got := rec.validReport(rec.epoch); got == nil || got.status != GoalStatusRunning { |
| 321 | t.Fatalf("validReport = %+v", got) |
| 322 | } |
| 323 | }) |
| 324 | |
| 325 | t.Run("continue upgrades to complete", func(t *testing.T) { |
| 326 | _, rec := newRec(t) |
| 327 | if _, err := rec.RecordGoalReport(report(GoalStatusRunning, "working")); err != nil { |
| 328 | t.Fatal(err) |
| 329 | } |
| 330 | if _, err := rec.RecordGoalReport(report(GoalStatusComplete, "")); err != nil { |
| 331 | t.Fatalf("continue → complete upgrade must be allowed: %v", err) |
| 332 | } |
| 333 | if got := rec.validReport(rec.epoch); got == nil || got.status != GoalStatusComplete { |
| 334 | t.Fatalf("validReport = %+v, want complete", got) |
| 335 | } |
| 336 | }) |
| 337 | |
| 338 | t.Run("terminal conflicts rejected", func(t *testing.T) { |
| 339 | _, rec := newRec(t) |
| 340 | if _, err := rec.RecordGoalReport(report(GoalStatusComplete, "")); err != nil { |
| 341 | t.Fatal(err) |
| 342 | } |
| 343 | if _, err := rec.RecordGoalReport(report(GoalStatusBlocked, "actually stuck")); err == nil { |
| 344 | t.Fatal("terminal complete must reject a later blocked report") |
| 345 | } |
| 346 | if _, err := rec.RecordGoalReport(report(GoalStatusRunning, "just kidding")); err == nil { |
| 347 | t.Fatal("terminal complete must reject a later continue report") |
| 348 | } |
| 349 | }) |
| 350 | |
| 351 | t.Run("conflicting non-terminal rejected", func(t *testing.T) { |
| 352 | _, rec := newRec(t) |
| 353 | if _, err := rec.RecordGoalReport(report(GoalStatusRunning, "doing A")); err != nil { |
| 354 | t.Fatal(err) |
| 355 | } |
| 356 | if _, err := rec.RecordGoalReport(report(GoalStatusRunning, "doing B")); err == nil { |
| 357 | t.Fatal("conflicting continue reports must be rejected") |
| 358 | } |
| 359 | }) |
| 360 | |
| 361 | t.Run("stale epoch invalidates report", func(t *testing.T) { |
| 362 | g, rec := newRec(t) |
| 363 | if _, err := rec.RecordGoalReport(report(GoalStatusComplete, "")); err != nil { |
| 364 | t.Fatal(err) |
| 365 | } |
| 366 | // The goal is replaced: epoch bumps, scope rotates. |
| 367 | g.set("replacement", GoalResearchAuto, "", nil) |
| 368 | if got := rec.validReport(rec.epoch); got != nil { |
| 369 | t.Fatalf("stale recorder report = %+v, want nil", got) |
| 370 | } |
| 371 | }) |
| 372 | |
| 373 | t.Run("late record after replacement rejected", func(t *testing.T) { |
| 374 | g, rec := newRec(t) |
| 375 | g.set("replacement", GoalResearchAuto, "", nil) |
| 376 | if _, err := rec.RecordGoalReport(report(GoalStatusComplete, "")); err == nil { |
| 377 | t.Fatal("late record on a replaced goal must be rejected") |
| 378 | } |
| 379 | }) |
| 380 | |
| 381 | t.Run("usage folds only for matching lifecycle", func(t *testing.T) { |
| 382 | g, rec := newRec(t) |
| 383 | rec.addUsage(150) |
| 384 | if g.tokensUsed != 150 { |
| 385 | t.Fatalf("tokensUsed = %d, want 150", g.tokensUsed) |
| 386 | } |
| 387 | g.set("replacement", GoalResearchAuto, "", nil) |
| 388 | rec.addUsage(50) |
| 389 | if g.tokensUsed != 0 { |
| 390 | t.Fatalf("stale usage folded into replacement goal: %d", g.tokensUsed) |
| 391 | } |
| 392 | }) |
| 393 | } |
| 394 | |
| 395 | // TestGoalUsageTeeAttributesScopedBillableCallsAndExcludesTitle covers the |
| 396 | // observational token accounting surface: executor/subagent-style usage counts, |
| 397 | // title generation does not. |
| 398 | func TestGoalUsageTeeAttributesScopedBillableCallsAndExcludesTitle(t *testing.T) { |
| 399 | tee := NewGoalUsageTee(event.Discard).(*goalUsageTee) |
| 400 | g := &goalMachine{goal: "ship it", status: GoalStatusRunning} |
| 401 | g.budgetClass = budgetClassWrite |
| 402 | g.turnsLimit = budgetQuota(budgetClassWrite) |
| 403 | g.tokensLimit = 0 |
| 404 | g.noProgressLimit = defaultNoProgressLimit |
| 405 | g.scopeID = newGoalScopeID() |
| 406 | rec := g.newTurnRecorder(g.scopeID, g.continuationEpoch) |
| 407 | tee.setActiveRecorder(rec) |
| 408 | |
| 409 | usage := func(tokens int) *provider.Usage { return &provider.Usage{TotalTokens: tokens} } |
| 410 | tee.Emit(event.Event{Kind: event.Usage, Usage: usage(100), UsageSource: event.UsageSourceExecutor}) |
| 411 | tee.Emit(event.Event{Kind: event.Usage, Usage: usage(200), UsageSource: event.UsageSourcePlanner}) |
| 412 | tee.Emit(event.Event{Kind: event.Usage, Usage: usage(300), UsageSource: event.UsageSourceSubagent}) |
| 413 | tee.Emit(event.Event{Kind: event.Usage, Usage: usage(400), UsageSource: event.UsageSourceCompaction}) |
| 414 | tee.Emit(event.Event{Kind: event.Usage, Usage: usage(500), UsageSource: event.UsageSourceRecoveryReviewer}) |
| 415 | tee.Emit(event.Event{Kind: event.Usage, Usage: usage(600), UsageSource: event.UsageSourceGoalEvaluator}) |
| 416 | tee.Emit(event.Event{Kind: event.Usage, Usage: usage(700), UsageSource: event.UsageSourceCapabilityRouter}) |
| 417 | tee.Emit(event.Event{Kind: event.Usage, Usage: usage(800), UsageSource: event.UsageSourceClassifier}) |
| 418 | // Title generation and unrelated background calls never count. |
| 419 | tee.Emit(event.Event{Kind: event.Usage, Usage: usage(900), UsageSource: event.UsageSourceTitle}) |
| 420 | tee.Emit(event.Event{Kind: event.Usage, Usage: usage(1000), UsageSource: event.UsageSourceTitle}) |
| 421 | |
| 422 | if rec.usageTokens() != 100+200+300+400+500+600+700+800 { |
| 423 | t.Fatalf("usageTokens = %d, want 3600", rec.usageTokens()) |
| 424 | } |
| 425 | if g.tokensUsed != 3600 { |
| 426 | t.Fatalf("live goal tokens = %d, want 3600", g.tokensUsed) |
| 427 | } |
| 428 | |
| 429 | // No active goal turn → nothing folds. |
| 430 | tee.setActiveRecorder(nil) |
| 431 | tee.Emit(event.Event{Kind: event.Usage, Usage: usage(50), UsageSource: event.UsageSourceExecutor}) |
| 432 | if rec.usageTokens() != 3600 { |
| 433 | t.Fatalf("usageTokens after span close = %d, want 3600", rec.usageTokens()) |
| 434 | } |
| 435 | } |
| 436 | |
| 437 | func TestBudgetClassForBareFaultIsWrite(t *testing.T) { |
| 438 | // User-reported Chinese bare fault → write turn quota (20), no token ceiling. |
| 439 | class := budgetClassFor("数据模型管理器又出现历史 BUG 了……", GoalResearchAuto) |
| 440 | if class != budgetClassWrite { |
| 441 | t.Fatalf("budget class = %q, want write", class) |
| 442 | } |
| 443 | if turns := budgetQuota(class); turns != 20 { |
| 444 | t.Fatalf("write turn quota = %d, want 20", turns) |
| 445 | } |
| 446 | // Consultative / diagnostic fault statements stay simple. |
| 447 | for _, goal := range []string{ |
| 448 | "为什么会出现这个 BUG?", |
| 449 | "只分析原因,不要修改代码。", |
| 450 | "诊断数据库连接失败原因。", |
| 451 | "复现并定位问题,但不要修复。", |
| 452 | } { |
| 453 | if got := budgetClassFor(goal, GoalResearchAuto); got != budgetClassSimple { |
| 454 | t.Errorf("budgetClassFor(%q) = %q, want simple", goal, got) |
| 455 | } |
| 456 | } |
| 457 | // Explicit mutation verbs remain write. |
| 458 | if got := budgetClassFor("fix the crash in settings", GoalResearchAuto); got != budgetClassWrite { |
| 459 | t.Fatalf("explicit fix class = %q, want write", got) |
| 460 | } |
| 461 | } |
| 462 | |
| 463 | func TestGoalLegacyBudgetTokensSidecarAutoResumes(t *testing.T) { |
| 464 | dir := t.TempDir() |
| 465 | path := filepath.Join(dir, "session.jsonl") |
| 466 | // Old sidecar: paused solely because of the removed token hard limit. |
| 467 | state := goalState{ |
| 468 | Goal: "应用打开设置时崩溃", |
| 469 | Status: GoalStatusBlocked, |
| 470 | StopCause: stopCauseBudgetTokens, |
| 471 | Block: "token budget exhausted (0/200000 tokens used)", |
| 472 | BudgetClass: budgetClassWrite, |
| 473 | TurnsUsed: 1, |
| 474 | TurnsLimit: 20, |
| 475 | TokensUsed: 214_000, |
| 476 | TokensLimit: 200_000, |
| 477 | BudgetExtensions: 0, |
| 478 | NoProgressLimit: defaultNoProgressLimit, |
| 479 | Todos: []evidence.TodoItem{{ |
| 480 | Content: "verify the repaired model mapping", Status: "in_progress", |
| 481 | }}, |
| 482 | } |
| 483 | raw, err := json.Marshal(state) |
| 484 | if err != nil { |
| 485 | t.Fatal(err) |
| 486 | } |
| 487 | if err := os.WriteFile(store.SessionGoalState(path), raw, 0o600); err != nil { |
| 488 | t.Fatal(err) |
| 489 | } |
| 490 | g := &goalMachine{} |
| 491 | migPath, migData, migrated := g.restoreFromState(path) |
| 492 | if !migrated { |
| 493 | t.Fatal("legacy budget_tokens pause must migrate") |
| 494 | } |
| 495 | if g.status != GoalStatusRunning || g.stopCause != "" { |
| 496 | t.Fatalf("status/stopCause = %q/%q, want running/empty", g.status, g.stopCause) |
| 497 | } |
| 498 | if g.block != "" { |
| 499 | t.Fatalf("block = %q, want empty after legacy token pause migration", g.block) |
| 500 | } |
| 501 | if g.tokensUsed != 214_000 { |
| 502 | t.Fatalf("tokensUsed = %d, want preserved 214000", g.tokensUsed) |
| 503 | } |
| 504 | if g.tokensLimit != 0 { |
| 505 | t.Fatalf("tokensLimit = %d, want 0", g.tokensLimit) |
| 506 | } |
| 507 | if g.turnsUsed != 1 || g.turnsLimit != 20 { |
| 508 | t.Fatalf("turns = %d/%d, want 1/20", g.turnsUsed, g.turnsLimit) |
| 509 | } |
| 510 | if err := g.writeStateErr(migPath, migData); err != nil { |
| 511 | t.Fatal(err) |
| 512 | } |
| 513 | var migratedState goalState |
| 514 | if err := json.Unmarshal(migData, &migratedState); err != nil { |
| 515 | t.Fatal(err) |
| 516 | } |
| 517 | if len(migratedState.Todos) != 1 || migratedState.Todos[0].Content != "verify the repaired model mapping" { |
| 518 | t.Fatalf("migration lost persisted todos: %+v", migratedState.Todos) |
| 519 | } |
| 520 | // Second load must stay running without re-entering the legacy pause. |
| 521 | g2 := &goalMachine{} |
| 522 | if _, _, migrated2 := g2.restoreFromState(path); migrated2 { |
| 523 | t.Fatal("normalized sidecar migrated a second time") |
| 524 | } |
| 525 | if g2.status != GoalStatusRunning || g2.stopCause != "" { |
| 526 | t.Fatalf("second load = %q/%q, want running/empty", g2.status, g2.stopCause) |
| 527 | } |
| 528 | } |
| 529 | |
| 530 | func TestGoalLargeTokenUsageDoesNotExhaustBudget(t *testing.T) { |
| 531 | g := &goalMachine{ |
| 532 | goal: "ship", status: GoalStatusRunning, |
| 533 | budgetClass: budgetClassSimple, turnsLimit: 10, tokensUsed: 900_000, tokensLimit: 0, |
| 534 | noProgressLimit: defaultNoProgressLimit, |
| 535 | } |
| 536 | if g.budgetExhausted() { |
| 537 | t.Fatal("budgetExhausted must ignore tokensUsed") |
| 538 | } |
| 539 | res := g.advance(goalAdvanceInput{ |
| 540 | report: &goalTurnReport{status: GoalStatusRunning, reason: "progress"}, |
| 541 | progressBefore: "a", |
| 542 | progressAfter: "b", |
| 543 | }) |
| 544 | if !res.cont { |
| 545 | t.Fatal("goal with large tokensUsed must continue while turns remain") |
| 546 | } |
| 547 | } |
| 548 | |
| 549 | // TestGoalUsageTotalTokensFallback checks the prompt+completion fallback when |
| 550 | // TotalTokens is missing (never double-counting cache hit/miss). |
| 551 | func TestGoalUsageTotalTokensFallback(t *testing.T) { |
| 552 | u := &provider.Usage{PromptTokens: 100, CompletionTokens: 20, CacheHitTokens: 90} |
| 553 | if got := usageTotalTokens(u); got != 120 { |
| 554 | t.Fatalf("fallback = %d, want 120 (prompt+completion, no cache double count)", got) |
| 555 | } |
| 556 | u.TotalTokens = 200 |
| 557 | if got := usageTotalTokens(u); got != 200 { |
| 558 | t.Fatalf("TotalTokens preferred = %d, want 200", got) |
| 559 | } |
| 560 | } |
| 561 | |
| 562 | // TestGoalSidecarCompatRestoresOldAndNewFields pins the compatibility contract: |
| 563 | // an old sidecar without the budget fields restores with re-derived defaults, |
| 564 | // and a new sidecar's pause (blocked + stopCause) survives a controller rebuild |
| 565 | // without failing open. |
| 566 | func TestGoalSidecarCompatRestoresOldAndNewFields(t *testing.T) { |
| 567 | t.Run("old sidecar restores with defaults", func(t *testing.T) { |
| 568 | dir := t.TempDir() |
| 569 | path := filepath.Join(dir, "session.jsonl") |
| 570 | // Old sidecar: only goal/status/turns — no budget fields. |
| 571 | data := []byte(`{"goal":"legacy goal","status":"running","turns":3}`) |
| 572 | if err := os.WriteFile(store.SessionGoalState(path), data, 0o600); err != nil { |
| 573 | t.Fatal(err) |
| 574 | } |
| 575 | exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard) |
| 576 | c := New(Options{Executor: exec, SessionDir: dir, Label: "test"}) |
| 577 | c.Resume(agent.NewSession("sys"), path) |
| 578 | rt := c.GoalRuntime() |
| 579 | if rt.TurnsUsed != 3 { |
| 580 | t.Fatalf("TurnsUsed = %d, want 3 (legacy Turns carried over)", rt.TurnsUsed) |
| 581 | } |
| 582 | if rt.TokensUsed != 0 { |
| 583 | t.Fatalf("TokensUsed = %d, want 0 (no legacy token record)", rt.TokensUsed) |
| 584 | } |
| 585 | if rt.TurnsLimit == 0 { |
| 586 | t.Fatalf("turn limit not re-derived: %+v", rt) |
| 587 | } |
| 588 | if rt.TokensLimit != 0 { |
| 589 | t.Fatalf("TokensLimit = %d, want 0 (no hard token limit)", rt.TokensLimit) |
| 590 | } |
| 591 | }) |
| 592 | |
| 593 | t.Run("new sidecar pause survives rebuild", func(t *testing.T) { |
| 594 | dir := t.TempDir() |
| 595 | path := filepath.Join(dir, "session.jsonl") |
| 596 | exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard) |
| 597 | c := New(Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"}) |
| 598 | c.SetGoal("ship the release") |
| 599 | c.goals.pauseFor(stopCauseBudgetTurns, "turn budget exhausted", nil) |
| 600 | statePath, data, ok := c.goals.buildStateLocked(nil) |
| 601 | if !ok { |
| 602 | t.Fatal("no persisted state") |
| 603 | } |
| 604 | if err := os.WriteFile(statePath, data, 0o600); err != nil { |
| 605 | t.Fatal(err) |
| 606 | } |
| 607 | |
| 608 | freshExec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard) |
| 609 | fresh := New(Options{Executor: freshExec, SessionDir: dir, Label: "fresh"}) |
| 610 | fresh.Resume(agent.NewSession("sys"), path) |
| 611 | if fresh.GoalStatus() != GoalStatusBlocked { |
| 612 | t.Fatalf("restored status = %q, want blocked (safe pause never fails open)", fresh.GoalStatus()) |
| 613 | } |
| 614 | if rt := fresh.GoalRuntime(); rt.StopCause != stopCauseBudgetTurns { |
| 615 | t.Fatalf("restored stop cause = %q, want %q", rt.StopCause, stopCauseBudgetTurns) |
| 616 | } |
| 617 | // Resuming a budget-paused goal extends the budget. |
| 618 | if !fresh.ResumeGoal() { |
| 619 | t.Fatal("resume rejected the restored paused goal") |
| 620 | } |
| 621 | if rt := fresh.GoalRuntime(); rt.BudgetExtensions != 1 { |
| 622 | t.Fatalf("budget extensions = %d, want 1 after resuming a budget pause", rt.BudgetExtensions) |
| 623 | } |
| 624 | }) |
| 625 | } |
| 626 | |
| 627 | // TestGoalPauseResumeCommands covers the /goal pause and /goal resume CLI |
| 628 | // surface plus the runtime view. |
| 629 | func TestGoalPauseResumeCommands(t *testing.T) { |
| 630 | cmd, ok := ParseGoalCommand("/goal pause") |
| 631 | if !ok || cmd.Action != GoalCommandPause { |
| 632 | t.Fatalf("ParseGoalCommand(/goal pause) = %+v", cmd) |
| 633 | } |
| 634 | cmd, ok = ParseGoalCommand("/goal resume") |
| 635 | if !ok || cmd.Action != GoalCommandResume { |
| 636 | t.Fatalf("ParseGoalCommand(/goal resume) = %+v", cmd) |
| 637 | } |
| 638 | cmd, ok = ParseGoalCommand("/goal") |
| 639 | if !ok || cmd.Action != GoalCommandStatus { |
| 640 | t.Fatalf("ParseGoalCommand(/goal) = %+v", cmd) |
| 641 | } |
| 642 | |
| 643 | c := New(Options{Sink: event.Discard}) |
| 644 | if c.PauseGoal() { |
| 645 | t.Fatal("PauseGoal without a goal must return false") |
| 646 | } |
| 647 | c.SetGoal("long-running research") |
| 648 | if !c.PauseGoal() { |
| 649 | t.Fatal("PauseGoal on a running goal must return true") |
| 650 | } |
| 651 | if got := c.GoalStatus(); got != GoalStatusBlocked { |
| 652 | t.Fatalf("GoalStatus() = %q, want blocked", got) |
| 653 | } |
| 654 | if rt := c.GoalRuntime(); rt.StopCause != stopCauseManual { |
| 655 | t.Fatalf("StopCause = %q, want manual", rt.StopCause) |
| 656 | } |
| 657 | // The goal text and budget survive the pause. |
| 658 | if got := c.Goal(); got != "long-running research" { |
| 659 | t.Fatalf("Goal() = %q, want preserved", got) |
| 660 | } |
| 661 | if !c.ResumeGoal() { |
| 662 | t.Fatal("ResumeGoal on a manually paused goal must return true") |
| 663 | } |
| 664 | if got := c.GoalStatus(); got != GoalStatusRunning { |
| 665 | t.Fatalf("GoalStatus() after resume = %q, want running", got) |
| 666 | } |
| 667 | if rt := c.GoalRuntime(); rt.StopCause != "" { |
| 668 | t.Fatalf("StopCause after resume = %q, want cleared", rt.StopCause) |
| 669 | } |
| 670 | } |
| 671 | |
| 672 | // TestGoalRuntimeViewPopulatesFromController covers the runtime view surface |
| 673 | // the CLI and desktop read. |
| 674 | func TestGoalRuntimeViewPopulatesFromController(t *testing.T) { |
| 675 | c := New(Options{Sink: event.Discard}) |
| 676 | c.SetGoal("finish the migration") |
| 677 | rt := c.GoalRuntime() |
| 678 | if rt.TurnsUsed != 0 || rt.TurnsLimit == 0 || rt.NoProgressLimit == 0 { |
| 679 | t.Fatalf("runtime view = %+v, want derived turn budget defaults", rt) |
| 680 | } |
| 681 | if rt.TokensLimit != 0 { |
| 682 | t.Fatalf("TokensLimit = %d, want 0 (no hard token limit)", rt.TokensLimit) |
| 683 | } |
| 684 | } |
| 685 | |
| 686 | // TestFooterTextDoesNotDriveGoalState pins the acceptance criterion: a |
| 687 | // historical [goal:complete] footer in the latest answer never influences the |
| 688 | // FSM — only the structured tool report does. |
| 689 | func TestFooterTextDoesNotDriveGoalState(t *testing.T) { |
| 690 | prov := &scriptedTurns{turns: [][]provider.Chunk{textTurn("All done.\n\n[goal:complete]")}} |
| 691 | c, _, events := goalRuntimeController(t, prov, &fakeGoalEvaluator{outcome: goaleval.OutcomeContinue, reason: "work is ongoing"}) |
| 692 | c.Submit("/goal migrate the storage") |
| 693 | waitGoalTurnDone(t, events) |
| 694 | // The footer alone must never complete the goal: the evaluator's continue |
| 695 | // keeps it going until the no-progress gate pauses it. |
| 696 | if got := c.GoalStatus(); got == GoalStatusComplete { |
| 697 | t.Fatal("a [goal:complete] footer must not complete the goal") |
| 698 | } |
| 699 | if rt := c.GoalRuntime(); rt.StopCause != stopCauseNoProgress { |
| 700 | t.Fatalf("runtime = %+v, want no-progress pause (footer ignored)", rt) |
| 701 | } |
| 702 | c.ClearGoal() |
| 703 | for _, m := range c.History() { |
| 704 | if m.Role == provider.RoleUser && strings.Contains(m.Content, "update_goal") { |
| 705 | return |
| 706 | } |
| 707 | } |
| 708 | t.Fatal("goal prompt should instruct the update_goal protocol") |
| 709 | } |
| 710 | |
| 711 | // minimalFakeTool is a no-op tool for delivery-flow tests. |
| 712 | type minimalFakeTool struct { |
| 713 | name string |
| 714 | readOnly bool |
| 715 | } |
| 716 | |
| 717 | func (f minimalFakeTool) Name() string { return f.name } |
| 718 | func (f minimalFakeTool) Description() string { return "" } |
| 719 | func (f minimalFakeTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) } |
| 720 | func (f minimalFakeTool) ReadOnly() bool { return f.readOnly } |
| 721 | func (f minimalFakeTool) Execute(context.Context, json.RawMessage) (string, error) { |
| 722 | return f.name + " done", nil |
| 723 | } |
| 724 | |
| 725 | // TestGoalDeliveryWorkflowCompletesAfterVerifiedSignoff covers the |
| 726 | // Goal + Delivery combination: the model works (edit → verify → review → |
| 727 | // complete_step), reports complete via update_goal, and the goal completes — |
| 728 | // no user-facing recovery card. |
| 729 | func TestGoalDeliveryWorkflowCompletesAfterVerifiedSignoff(t *testing.T) { |
| 730 | todoWrite, _ := tool.LookupBuiltin("todo_write") |
| 731 | completeStep, _ := tool.LookupBuiltin("complete_step") |
| 732 | reg := goalRegistry() |
| 733 | reg.Add(todoWrite) |
| 734 | reg.Add(completeStep) |
| 735 | reg.Add(minimalFakeTool{name: "write_file"}) |
| 736 | reg.Add(minimalFakeTool{name: "read_file", readOnly: true}) |
| 737 | reg.Add(minimalFakeTool{name: "bash"}) |
| 738 | |
| 739 | prov := &scriptedTurns{turns: flattenTurns( |
| 740 | [][]provider.Chunk{ |
| 741 | {toolCallChunk("t0", "todo_write", `{"todos":[{"content":"Ship main","status":"in_progress"}]}`), {Type: provider.ChunkDone}}, |
| 742 | {toolCallChunk("w1", "write_file", `{"path":"main.go"}`), {Type: provider.ChunkDone}}, |
| 743 | {toolCallChunk("rv", "read_file", `{"path":"main.go"}`), {Type: provider.ChunkDone}}, |
| 744 | {toolCallChunk("vf", "bash", `{"command":"go test ./..."}`), {Type: provider.ChunkDone}}, |
| 745 | {toolCallChunk("sg", "complete_step", `{"step":"Ship main","result":"implemented","evidence":[{"kind":"verification","summary":"tests pass","command":"go test ./..."}]}`), {Type: provider.ChunkDone}}, |
| 746 | {toolCallChunk("ug", "update_goal", `{"status":"complete","reason":""}`), {Type: provider.ChunkDone}}, |
| 747 | textTurn("Ship main delivered."), |
| 748 | }, |
| 749 | )} |
| 750 | ag := agent.New(prov, reg, agent.NewSession(""), agent.Options{DeliveryProfile: true}, event.Discard) |
| 751 | done := make(chan event.Event, 1) |
| 752 | var doneReadiness *event.FinalReadiness |
| 753 | c := New(Options{ |
| 754 | Runner: ag, |
| 755 | Executor: ag, |
| 756 | Sink: event.FuncSink(func(e event.Event) { |
| 757 | if e.Kind == event.TurnDone { |
| 758 | doneReadiness = e.Readiness |
| 759 | done <- e |
| 760 | } |
| 761 | }), |
| 762 | }) |
| 763 | c.Submit("/goal implement main") |
| 764 | <-done |
| 765 | |
| 766 | if got := c.GoalStatus(); got != GoalStatusComplete { |
| 767 | t.Fatalf("GoalStatus() = %q, want complete after verified sign-off", got) |
| 768 | } |
| 769 | if doneReadiness != nil { |
| 770 | t.Fatalf("TurnDone.Readiness = %+v, want nil (Goal absorbs readiness; no recovery card)", doneReadiness) |
| 771 | } |
| 772 | if got := c.Goal(); got != "" { |
| 773 | t.Fatalf("completed goal should be cleared, got %q", got) |
| 774 | } |
| 775 | } |
| 776 | |
| 777 | // TestPlainDeliveryReadinessFailureSurfacesRecoveryCardWithoutRetries covers |
| 778 | // the plain (non-Goal) Delivery combination: readiness failure ends the run on |
| 779 | // the first final answer, surfaces the recovery card, and never auto-continues. |
| 780 | func TestPlainDeliveryReadinessFailureSurfacesRecoveryCardWithoutRetries(t *testing.T) { |
| 781 | todoWrite, _ := tool.LookupBuiltin("todo_write") |
| 782 | reg := tool.NewRegistry() |
| 783 | reg.Add(todoWrite) |
| 784 | reg.Add(minimalFakeTool{name: "write_file"}) |
| 785 | prov := &scriptedTurns{turns: [][]provider.Chunk{ |
| 786 | {toolCallChunk("w1", "write_file", `{"path":"main.go"}`), {Type: provider.ChunkDone}}, |
| 787 | {toolCallChunk("t0", "todo_write", `{"todos":[{"content":"Ship main","status":"in_progress"}]}`), {Type: provider.ChunkDone}}, |
| 788 | textTurn("premature final"), |
| 789 | textTurn("extra turn that must never run"), |
| 790 | }} |
| 791 | ag := agent.New(prov, reg, agent.NewSession(""), agent.Options{DeliveryProfile: true}, event.Discard) |
| 792 | done := make(chan event.Event, 1) |
| 793 | c := New(Options{ |
| 794 | Runner: ag, |
| 795 | Executor: ag, |
| 796 | Sink: event.FuncSink(func(e event.Event) { |
| 797 | if e.Kind == event.TurnDone { |
| 798 | done <- e |
| 799 | } |
| 800 | }), |
| 801 | }) |
| 802 | |
| 803 | c.Submit("implement main") |
| 804 | ev := <-done |
| 805 | if ev.Readiness == nil || len(ev.Readiness.Missing) == 0 { |
| 806 | t.Fatalf("TurnDone.Readiness = %+v, want missing requirements for the recovery card", ev.Readiness) |
| 807 | } |
| 808 | if prov.call != 3 { |
| 809 | t.Fatalf("provider calls = %d, want 3 (work turn + final answer, no readiness retries)", prov.call) |
| 810 | } |
| 811 | if got := c.GoalStatus(); got != GoalStatusStopped { |
| 812 | t.Fatalf("GoalStatus() = %q, want stopped (no goal involved)", got) |
| 813 | } |
| 814 | } |
| 815 |