| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "testing" |
| 9 | |
| 10 | "reasonix/internal/agent" |
| 11 | "reasonix/internal/event" |
| 12 | "reasonix/internal/evidence" |
| 13 | "reasonix/internal/provider" |
| 14 | "reasonix/internal/store" |
| 15 | "reasonix/internal/tool" |
| 16 | ) |
| 17 | |
| 18 | // goalRuntimeController wires a controller with a scripted reporting model. |
| 19 | // The legacy evaluator option is accepted but must never be called. |
| 20 | func goalRuntimeController(t *testing.T, prov provider.Provider, eval any) (*Controller, *agent.Agent, <-chan event.Event) { |
| 21 | t.Helper() |
| 22 | return goalRuntimeControllerWithTokenBudget(t, prov, eval, 0) |
| 23 | } |
| 24 | |
| 25 | func goalRuntimeControllerWithTokenBudget(t *testing.T, prov provider.Provider, eval any, tokens int) (*Controller, *agent.Agent, <-chan event.Event) { |
| 26 | t.Helper() |
| 27 | ag := agent.New(prov, goalRegistry(), agent.NewSession(""), agent.Options{}, event.Discard) |
| 28 | events := make(chan event.Event, 8) |
| 29 | c := newOwnedTestController(t, Options{ |
| 30 | Runner: ag, |
| 31 | Executor: ag, |
| 32 | GoalEvaluator: eval, |
| 33 | GoalTokenBudget: tokens, |
| 34 | Sink: event.FuncSink(func(e event.Event) { |
| 35 | if e.Kind == event.TurnDone || e.Kind == event.Notice { |
| 36 | events <- e |
| 37 | } |
| 38 | }), |
| 39 | }) |
| 40 | return c, ag, events |
| 41 | } |
| 42 | |
| 43 | func TestBudgetClassForBareFaultIsWrite(t *testing.T) { |
| 44 | // User-reported Chinese bare fault keeps its legacy compatibility class. |
| 45 | class := budgetClassForLegacyMode("数据模型管理器又出现历史 BUG 了……", GoalResearchAuto) |
| 46 | if class != budgetClassWrite { |
| 47 | t.Fatalf("budget class = %q, want write", class) |
| 48 | } |
| 49 | // Consultative / diagnostic fault statements stay simple. |
| 50 | for _, goal := range []string{ |
| 51 | "为什么会出现这个 BUG?", |
| 52 | "只分析原因,不要修改代码。", |
| 53 | "诊断数据库连接失败原因。", |
| 54 | "复现并定位问题,但不要修复。", |
| 55 | } { |
| 56 | if got := budgetClassForLegacyMode(goal, GoalResearchAuto); got != budgetClassSimple { |
| 57 | t.Errorf("budgetClassFor(%q) = %q, want simple", goal, got) |
| 58 | } |
| 59 | } |
| 60 | // Explicit mutation verbs remain write. |
| 61 | if got := budgetClassForLegacyMode("fix the crash in settings", GoalResearchAuto); got != budgetClassWrite { |
| 62 | t.Fatalf("explicit fix class = %q, want write", got) |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | func TestGoalLegacyBudgetTokensSidecarAutoResumes(t *testing.T) { |
| 67 | dir := t.TempDir() |
| 68 | path := filepath.Join(dir, "session.jsonl") |
| 69 | // Old sidecar: paused solely because of the removed token hard limit. |
| 70 | state := goalState{ |
| 71 | Goal: "应用打开设置时崩溃", |
| 72 | Status: GoalStatusBlocked, |
| 73 | StopCause: stopCauseBudgetTokens, |
| 74 | Block: "token budget exhausted (0/200000 tokens used)", |
| 75 | BudgetClass: budgetClassWrite, |
| 76 | TurnsUsed: 1, |
| 77 | TurnsLimit: 20, |
| 78 | TokensUsed: 214_000, |
| 79 | TokensLimit: 200_000, |
| 80 | BudgetExtensions: 0, |
| 81 | NoProgressLimit: 0, |
| 82 | Todos: []evidence.TodoItem{{ |
| 83 | Content: "verify the repaired model mapping", Status: "in_progress", |
| 84 | }}, |
| 85 | } |
| 86 | raw, err := json.Marshal(state) |
| 87 | if err != nil { |
| 88 | t.Fatal(err) |
| 89 | } |
| 90 | if err := os.WriteFile(store.SessionGoalState(path), raw, 0o600); err != nil { |
| 91 | t.Fatal(err) |
| 92 | } |
| 93 | g := &goalMachine{} |
| 94 | _, _, migrated, _ := g.restoreFromState(path) |
| 95 | if migrated || !g.disarmed { |
| 96 | t.Fatal("restore must normalize without writing or activating") |
| 97 | } |
| 98 | if g.status != GoalStatusRunning || g.stopCause != "" { |
| 99 | t.Fatalf("status/stopCause = %q/%q, want running/empty", g.status, g.stopCause) |
| 100 | } |
| 101 | if g.block != "" { |
| 102 | t.Fatalf("block = %q, want empty after legacy token pause migration", g.block) |
| 103 | } |
| 104 | if g.tokensUsed != 214_000 { |
| 105 | t.Fatalf("tokensUsed = %d, want preserved 214000", g.tokensUsed) |
| 106 | } |
| 107 | if g.tokensLimit != 0 { |
| 108 | t.Fatalf("tokensLimit = %d, want 0", g.tokensLimit) |
| 109 | } |
| 110 | if g.turnsUsed != 1 || g.turnsLimit != unlimitedGoalTurns { |
| 111 | t.Fatalf("turns = %d/%d, want 1/unlimited", g.turnsUsed, g.turnsLimit) |
| 112 | } |
| 113 | migData, err := os.ReadFile(goalStatePath(path)) |
| 114 | if err != nil { |
| 115 | t.Fatal(err) |
| 116 | } |
| 117 | var migratedState goalState |
| 118 | if err := json.Unmarshal(migData, &migratedState); err != nil { |
| 119 | t.Fatal(err) |
| 120 | } |
| 121 | if len(migratedState.Todos) != 1 || migratedState.Todos[0].Content != "verify the repaired model mapping" { |
| 122 | t.Fatalf("migration lost persisted todos: %+v", migratedState.Todos) |
| 123 | } |
| 124 | // Second load must stay running without re-entering the legacy pause. |
| 125 | g2 := &goalMachine{} |
| 126 | if _, _, migrated2, _ := g2.restoreFromState(path); migrated2 { |
| 127 | t.Fatal("normalized sidecar migrated a second time") |
| 128 | } |
| 129 | if g2.status != GoalStatusRunning || g2.stopCause != "" { |
| 130 | t.Fatalf("second load = %q/%q, want running/empty", g2.status, g2.stopCause) |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | // TestGoalUsageTotalTokensFallback checks the prompt+completion fallback when |
| 135 | // TotalTokens is missing (never double-counting cache hit/miss). |
| 136 | func TestGoalUsageTotalTokensFallback(t *testing.T) { |
| 137 | u := &provider.Usage{PromptTokens: 100, CompletionTokens: 20, CacheHitTokens: 90} |
| 138 | if got := usageTotalTokens(u); got != 120 { |
| 139 | t.Fatalf("fallback = %d, want 120 (prompt+completion, no cache double count)", got) |
| 140 | } |
| 141 | u.TotalTokens = 200 |
| 142 | if got := usageTotalTokens(u); got != 200 { |
| 143 | t.Fatalf("TotalTokens preferred = %d, want 200", got) |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | // TestGoalSidecarCompatRestoresOldAndNewFields pins the compatibility contract: |
| 148 | // an old sidecar without the budget fields restores with re-derived defaults, |
| 149 | // and a new sidecar's pause (blocked + stopCause) survives a controller rebuild |
| 150 | // without failing open. |
| 151 | func TestGoalSidecarCompatRestoresOldAndNewFields(t *testing.T) { |
| 152 | t.Run("old sidecar restores with defaults", func(t *testing.T) { |
| 153 | dir := t.TempDir() |
| 154 | path := filepath.Join(dir, "session.jsonl") |
| 155 | // Old sidecar: only goal/status/turns — no budget fields. |
| 156 | data := []byte(`{"goal":"legacy goal","status":"running","turns":3}`) |
| 157 | if err := os.WriteFile(store.SessionGoalState(path), data, 0o600); err != nil { |
| 158 | t.Fatal(err) |
| 159 | } |
| 160 | exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard) |
| 161 | c := newOwnedTestController(t, Options{Executor: exec, SessionDir: dir, Label: "test"}) |
| 162 | c.Resume(agent.NewSession("sys"), path) |
| 163 | rt := c.GoalRuntime() |
| 164 | if rt.TurnsUsed != 3 { |
| 165 | t.Fatalf("TurnsUsed = %d, want 3 (legacy Turns carried over)", rt.TurnsUsed) |
| 166 | } |
| 167 | if rt.TokensUsed != 0 { |
| 168 | t.Fatalf("TokensUsed = %d, want 0 (no legacy token record)", rt.TokensUsed) |
| 169 | } |
| 170 | if rt.TurnsLimit != 0 || rt.NoProgressLimit != 0 { |
| 171 | t.Fatalf("removed limits resurfaced: %+v", rt) |
| 172 | } |
| 173 | if rt.TokensLimit != 0 { |
| 174 | t.Fatalf("TokensLimit = %d, want 0 when no budget is configured", rt.TokensLimit) |
| 175 | } |
| 176 | }) |
| 177 | |
| 178 | t.Run("removed numeric pause auto-migrates on rebuild", func(t *testing.T) { |
| 179 | dir := t.TempDir() |
| 180 | path := filepath.Join(dir, "session.jsonl") |
| 181 | exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard) |
| 182 | c := newOwnedTestController(t, Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"}) |
| 183 | c.SetGoal("ship the release") |
| 184 | c.goals.pauseFor(stopCauseBudgetTurns, "turn budget exhausted") |
| 185 | statePath, data, ok := c.goals.buildStateLocked() |
| 186 | if !ok { |
| 187 | t.Fatal("no persisted state") |
| 188 | } |
| 189 | if err := os.WriteFile(statePath, data, 0o600); err != nil { |
| 190 | t.Fatal(err) |
| 191 | } |
| 192 | |
| 193 | freshExec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard) |
| 194 | fresh := newOwnedTestController(t, Options{Executor: freshExec, SessionDir: dir, Label: "fresh"}) |
| 195 | fresh.Resume(agent.NewSession("sys"), path) |
| 196 | if fresh.GoalStatus() != GoalStatusStopped { |
| 197 | t.Fatalf("restored status = %q, want running after numeric pause migration", fresh.GoalStatus()) |
| 198 | } |
| 199 | if rt := fresh.GoalRuntime(); rt.StopCause != "" || rt.TurnsLimit != 0 { |
| 200 | t.Fatalf("restored runtime = %+v, want continuous Goal", rt) |
| 201 | } |
| 202 | }) |
| 203 | } |
| 204 | |
| 205 | // TestGoalPauseResumeCommands covers the /goal pause and /goal resume CLI |
| 206 | // surface plus the runtime view. |
| 207 | func TestGoalPauseResumeCommands(t *testing.T) { |
| 208 | cmd, ok := ParseGoalCommand("/goal pause") |
| 209 | if !ok || cmd.Action != GoalCommandPause { |
| 210 | t.Fatalf("ParseGoalCommand(/goal pause) = %+v", cmd) |
| 211 | } |
| 212 | cmd, ok = ParseGoalCommand("/goal resume") |
| 213 | if !ok || cmd.Action != GoalCommandResume { |
| 214 | t.Fatalf("ParseGoalCommand(/goal resume) = %+v", cmd) |
| 215 | } |
| 216 | cmd, ok = ParseGoalCommand("/goal") |
| 217 | if !ok || cmd.Action != GoalCommandStatus { |
| 218 | t.Fatalf("ParseGoalCommand(/goal) = %+v", cmd) |
| 219 | } |
| 220 | |
| 221 | c := newOwnedTestController(t, Options{Sink: event.Discard}) |
| 222 | if c.PauseGoal() { |
| 223 | t.Fatal("PauseGoal without a goal must return false") |
| 224 | } |
| 225 | c.SetGoal("long-running research") |
| 226 | if !c.PauseGoal() { |
| 227 | t.Fatal("PauseGoal on a running goal must return true") |
| 228 | } |
| 229 | if got := c.GoalStatus(); got != GoalStatusBlocked { |
| 230 | t.Fatalf("GoalStatus() = %q, want blocked", got) |
| 231 | } |
| 232 | if rt := c.GoalRuntime(); rt.StopCause != stopCauseManual { |
| 233 | t.Fatalf("StopCause = %q, want manual", rt.StopCause) |
| 234 | } |
| 235 | // The goal text and budget survive the pause. |
| 236 | if got := c.Goal(); got != "long-running research" { |
| 237 | t.Fatalf("Goal() = %q, want preserved", got) |
| 238 | } |
| 239 | if !c.ResumeGoal() { |
| 240 | t.Fatal("ResumeGoal on a manually paused goal must return true") |
| 241 | } |
| 242 | if got := c.GoalStatus(); got != GoalStatusRunning { |
| 243 | t.Fatalf("GoalStatus() after resume = %q, want running", got) |
| 244 | } |
| 245 | if rt := c.GoalRuntime(); rt.StopCause != "" { |
| 246 | t.Fatalf("StopCause after resume = %q, want cleared", rt.StopCause) |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | // TestGoalRuntimeViewPopulatesFromController covers the runtime view surface |
| 251 | // the CLI and desktop read. |
| 252 | func TestGoalRuntimeViewPopulatesFromController(t *testing.T) { |
| 253 | c := newOwnedTestController(t, Options{Sink: event.Discard}) |
| 254 | c.SetGoal("finish the migration") |
| 255 | rt := c.GoalRuntime() |
| 256 | if rt.TurnsUsed != 0 || rt.TurnsLimit != 0 || rt.NoProgressLimit != 0 { |
| 257 | t.Fatalf("runtime view = %+v, want continuous defaults", rt) |
| 258 | } |
| 259 | if rt.TokensLimit != 0 { |
| 260 | t.Fatalf("TokensLimit = %d, want 0 (no hard token limit)", rt.TokensLimit) |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | // minimalFakeTool is a no-op tool for delivery-flow tests. |
| 265 | type minimalFakeTool struct { |
| 266 | name string |
| 267 | readOnly bool |
| 268 | } |
| 269 | |
| 270 | func (f minimalFakeTool) Name() string { return f.name } |
| 271 | func (f minimalFakeTool) Description() string { return "" } |
| 272 | func (f minimalFakeTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) } |
| 273 | func (f minimalFakeTool) ReadOnly() bool { return f.readOnly } |
| 274 | func (f minimalFakeTool) Execute(context.Context, json.RawMessage) (string, error) { |
| 275 | return f.name + " done", nil |
| 276 | } |
| 277 | |
| 278 | // TestRetiredDeliverySettingDoesNotCreateRecoveryCard covers a historical |
| 279 | // delivery value on an ordinary, non-Goal turn. |
| 280 | func TestRetiredDeliverySettingDoesNotCreateRecoveryCard(t *testing.T) { |
| 281 | todoWrite, _ := tool.LookupBuiltin("todo_write") |
| 282 | reg := tool.NewRegistry() |
| 283 | reg.Add(todoWrite) |
| 284 | reg.Add(minimalFakeTool{name: "write_file"}) |
| 285 | prov := &scriptedTurns{turns: [][]provider.Chunk{ |
| 286 | {toolCallChunk("w1", "write_file", `{"path":"main.go"}`), {Type: provider.ChunkDone}}, |
| 287 | {toolCallChunk("t0", "todo_write", `{"todos":[{"content":"Ship main","status":"in_progress"}]}`), {Type: provider.ChunkDone}}, |
| 288 | textTurn("premature final"), |
| 289 | textTurn("must not be consumed by a hidden readiness retry"), |
| 290 | }} |
| 291 | // "implement main" is an unanchored mutation. The retired delivery value |
| 292 | // must not turn its evidence gap into a current pause. |
| 293 | ag := agent.New(prov, reg, agent.NewSession(""), agent.Options{}, event.Discard) |
| 294 | done := make(chan event.Event, 1) |
| 295 | c := newOwnedTestController(t, Options{ |
| 296 | Runner: ag, |
| 297 | Executor: ag, |
| 298 | Sink: event.FuncSink(func(e event.Event) { |
| 299 | if e.Kind == event.TurnDone { |
| 300 | done <- e |
| 301 | } |
| 302 | }), |
| 303 | }) |
| 304 | |
| 305 | if err := c.SetQualityFloor(QualityFloorDelivery); err != nil { |
| 306 | t.Fatalf("SetQualityFloor: %v", err) |
| 307 | } |
| 308 | c.Submit("implement main") |
| 309 | ev := <-done |
| 310 | if ev.Readiness != nil { |
| 311 | t.Fatalf("TurnDone.Readiness = %+v, want no mode-created recovery card", ev.Readiness) |
| 312 | } |
| 313 | if prov.call != 3 { |
| 314 | t.Fatalf("provider calls = %d, want 3 (work + todo + final answer)", prov.call) |
| 315 | } |
| 316 | if got := c.GoalStatus(); got != GoalStatusStopped { |
| 317 | t.Fatalf("GoalStatus() = %q, want stopped (no goal involved)", got) |
| 318 | } |
| 319 | } |
| 320 |