返回 DeepSeek-Reasonix
recovery_gate_test.go
根目录 / internal / agent / recovery_gate_test.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "strings"
8 "testing"
9 "time"
10
11 "reasonix/internal/event"
12 "reasonix/internal/evidence"
13 "reasonix/internal/provider"
14 "reasonix/internal/tool"
15 )
16
17 type recordingRecoveryGate struct {
18 observation RecoveryObservation
19 proposals []RecoveryProposal
20 decision RecoveryDecision
21 }
22
23 func TestRecoveryPlanTransitionDetectsOnlyStructuralRewriteOfActivePlan(t *testing.T) {
24 a := &Agent{}
25 initial := json.RawMessage(`{"todos":[{"content":"Implement parser","status":"in_progress"}]}`)
26 if changed, _, _ := a.recoveryPlanTransition("todo_write", initial); changed {
27 t.Fatal("initial plan must stay on the fast path")
28 }
29
30 a.setTodoState([]evidence.TodoItem{
31 {Content: "Implement parser", Status: "in_progress"},
32 {Content: "Run tests", Status: "pending"},
33 })
34 progressOnly := json.RawMessage(`{"todos":[{"content":"Implement parser","status":"completed"},{"content":"Run tests","status":"in_progress"}]}`)
35 if changed, _, _ := a.recoveryPlanTransition("todo_write", progressOnly); changed {
36 t.Fatal("progress-only update must not invoke the plan reviewer")
37 }
38
39 replacement := json.RawMessage(`{"todos":[{"content":"Replace parser architecture","status":"in_progress"},{"content":"Run tests","status":"pending"}]}`)
40 changed, before, after := a.recoveryPlanTransition("todo_write", replacement)
41 if !changed {
42 t.Fatal("structural rewrite of active plan was not detected")
43 }
44 if !strings.Contains(before, "Implement parser") || !strings.Contains(after, "Replace parser architecture") {
45 t.Fatalf("plan evidence before=%q after=%q", before, after)
46 }
47 }
48
49 func TestRecoveryPlanTransitionIgnoresCompletedPriorPlan(t *testing.T) {
50 a := &Agent{}
51 a.setTodoState([]evidence.TodoItem{{Content: "Old task", Status: "completed"}})
52 next := json.RawMessage(`{"todos":[{"content":"New user task","status":"in_progress"}]}`)
53 if changed, _, _ := a.recoveryPlanTransition("todo_write", next); changed {
54 t.Fatal("a new task after a completed plan is not a mid-plan transition")
55 }
56 }
57
58 func (g *recordingRecoveryGate) ObserveResult(_ context.Context, observation RecoveryObservation) string {
59 g.observation = observation
60 return ""
61 }
62
63 func (g *recordingRecoveryGate) BeforeMutation(_ context.Context, proposal RecoveryProposal) (RecoveryDecision, error) {
64 g.proposals = append(g.proposals, proposal)
65 decision := g.decision
66 if decision == (RecoveryDecision{}) {
67 decision.Allow = true
68 }
69 return decision, nil
70 }
71
72 func TestAuthorizedRecoveryPlanTransitionCanReplaceCurrentTodo(t *testing.T) {
73 reg := tool.NewRegistry()
74 reg.Add(mustBuiltinTool(t, "todo_write"))
75 gate := &recordingRecoveryGate{decision: RecoveryDecision{
76 Allow: true, AuthorizePlanReplacement: true,
77 }}
78 a := New(nil, reg, NewSession(""), Options{RecoveryGate: gate}, event.Discard)
79 a.SeedTodoState([]evidence.TodoItem{
80 {Content: "Inspect environment", Status: "completed"},
81 {Content: "Implement parser", Status: "in_progress"},
82 {Content: "Run tests", Status: "pending"},
83 })
84
85 out := a.executeOne(context.Background(), provider.ToolCall{
86 ID: "replace-plan",
87 Name: "todo_write",
88 Arguments: `{"todos":[
89 {"content":"Inspect environment","status":"completed"},
90 {"content":"Replace parser architecture","status":"in_progress"},
91 {"content":"Run tests","status":"pending"}
92 ]}`,
93 })
94 if out.errMsg != "" {
95 t.Fatalf("authorized plan replacement was blocked: %+v", out)
96 }
97 if len(gate.proposals) != 1 || !gate.proposals[0].PlanTransition {
98 t.Fatalf("recovery proposals = %+v, want one plan transition", gate.proposals)
99 }
100 got := a.CanonicalTodoState()
101 if len(got) != 3 || got[0].Status != "completed" || got[1].Content != "Replace parser architecture" {
102 t.Fatalf("canonical todo state = %+v, want preserved history plus replacement", got)
103 }
104 }
105
106 func TestPlanTransitionNeedsDedicatedReplacementAuthorization(t *testing.T) {
107 reg := tool.NewRegistry()
108 reg.Add(mustBuiltinTool(t, "todo_write"))
109 gate := &recordingRecoveryGate{decision: RecoveryDecision{Allow: true}}
110 a := New(nil, reg, NewSession(""), Options{RecoveryGate: gate}, event.Discard)
111 a.SeedTodoState([]evidence.TodoItem{{Content: "Implement parser", Status: "in_progress"}})
112
113 out := a.executeOne(context.Background(), provider.ToolCall{
114 ID: "replace-plan-without-authorization",
115 Name: "todo_write",
116 Arguments: `{"todos":[{"content":"Replace parser architecture","status":"in_progress"}]}`,
117 })
118 if out.errMsg == "" || !strings.Contains(out.output, "cannot be removed or replaced") {
119 t.Fatalf("plain allow unexpectedly replaced current todo: %+v", out)
120 }
121 }
122
123 func TestObserveRecoveryResultMarksCancellation(t *testing.T) {
124 gate := &recordingRecoveryGate{}
125 a := &Agent{recoveryGate: gate}
126 a.observeRecoveryResult(
127 context.Background(),
128 "write_file",
129 json.RawMessage(`{"path":"a.go"}`),
130 false,
131 true,
132 "cancelled",
133 context.Canceled,
134 false,
135 false,
136 0,
137 )
138 if !gate.observation.Cancelled {
139 t.Fatalf("observation = %+v, want cancellation marked", gate.observation)
140 }
141 if gate.observation.TaskScopeID == "" {
142 t.Fatalf("observation = %+v, want a host-owned recovery scope", gate.observation)
143 }
144 }
145
146 func TestObserveRecoveryResultKeepsToolOwnedDeadlineAsFailure(t *testing.T) {
147 gate := &recordingRecoveryGate{}
148 a := &Agent{recoveryGate: gate}
149 a.observeRecoveryResult(
150 context.Background(),
151 "mcp__server__write",
152 json.RawMessage(`{"value":"x"}`),
153 false,
154 true,
155 "",
156 fmt.Errorf("MCP tool timed out after 30s: %w", context.DeadlineExceeded),
157 false,
158 false,
159 0,
160 )
161 if gate.observation.Cancelled {
162 t.Fatalf("observation = %+v, tool-owned deadline must remain a qualifying transient failure", gate.observation)
163 }
164 if !strings.Contains(gate.observation.ErrSummary, "timed out") {
165 t.Fatalf("observation = %+v, want timeout evidence preserved", gate.observation)
166 }
167 }
168
169 func TestObserveRecoveryResultMarksParentDeadlineCancellation(t *testing.T) {
170 gate := &recordingRecoveryGate{}
171 a := &Agent{recoveryGate: gate}
172 ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
173 defer cancel()
174 a.observeRecoveryResult(
175 ctx,
176 "mcp__server__write",
177 json.RawMessage(`{"value":"x"}`),
178 false,
179 true,
180 "",
181 context.DeadlineExceeded,
182 false,
183 false,
184 0,
185 )
186 if !gate.observation.Cancelled {
187 t.Fatalf("observation = %+v, parent deadline must remain a cancellation", gate.observation)
188 }
189 }
190
191 func TestRecoveryBlockSurfacesConcreteReason(t *testing.T) {
192 reg := tool.NewRegistry()
193 reg.Add(mustBuiltinTool(t, "write_file"))
194 gate := &recordingRecoveryGate{decision: RecoveryDecision{
195 Blocked: true,
196 Message: "blocked: Auto stopped repeating this operation after 3 consecutive failures: write a.go. Other operations remain available.",
197 }}
198 a := New(nil, reg, NewSession(""), Options{RecoveryGate: gate}, event.Discard)
199 out := a.executeOne(context.Background(), provider.ToolCall{
200 ID: "blocked-write", Name: "write_file", Arguments: `{"path":"a.go","content":"x"}`,
201 })
202 if !out.blocked || !strings.Contains(out.errMsg, "stopped repeating this operation") || strings.Contains(out.errMsg, "Auto Guard") {
203 t.Fatalf("recovery failure card = %+v", out)
204 }
205 }
206
206 lines GO