返回 DeepSeek-Reasonix
goal_runtime_test.go
根目录 / internal / control / goal_runtime_test.go
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
320 lines GO