返回 DeepSeek-Reasonix
recovery_e2e_test.go
根目录 / internal / control / recovery_e2e_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/permission"
13 "reasonix/internal/provider"
14 "reasonix/internal/recovery"
15 "reasonix/internal/tool"
16 )
17
18 // End-to-end: scripted provider fails verification, runs read-only diagnosis,
19 // then performs an external write without turning execution risk into a user
20 // decision. Also verifies recovery sidecar persistence and metrics.
21 func TestRecoveryCheckpointScriptedE2E(t *testing.T) {
22 dir := t.TempDir()
23 sessionPath := filepath.Join(dir, "session.jsonl")
24
25 bash := &recoveryWriteTool{name: "bash", failOnce: true}
26 read := &recoveryWriteTool{name: "read_file", readOnly: true}
27 reg := tool.NewRegistry()
28 reg.Add(bash)
29 reg.Add(read)
30
31 prov := &recordingProvider{streams: [][]provider.Chunk{
32 {{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "1", Name: "bash", Arguments: `{"command":"go test ./..."}`}}},
33 {{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "2", Name: "read_file", Arguments: `{"path":"main.go"}`}}},
34 {{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "3", Name: "bash", Arguments: `{"command":"git push origin feature"}`}}},
35 {{Type: provider.ChunkText, Text: "done"}},
36 }}
37
38 sess := agent.NewSession("You are a test agent.")
39 ag := agent.New(prov, reg, sess, agent.Options{MaxSteps: 10}, event.Discard)
40 // Leave SessionPath empty so autosave does not hold file locks on dir.
41 c := New(Options{
42 Runner: ag,
43 Executor: ag,
44 Policy: permission.Policy{Mode: permission.Allow},
45 })
46 t.Cleanup(func() { c.Close() })
47 c.SetToolApprovalMode(ToolApprovalAuto)
48 c.EnableInteractiveApproval()
49
50 if err := c.Run(context.Background(), "test then fix"); err != nil {
51 t.Fatalf("Run: %v", err)
52 }
53
54 if read.runs < 1 {
55 t.Fatalf("expected read-only diagnosis, runs=%d", read.runs)
56 }
57 if bash.runs != 2 {
58 t.Fatalf("bash runs = %d, want failed verification plus automatic push", bash.runs)
59 }
60
61 // Sidecar persistence (write a synthetic session path under temp dir).
62 c.mu.Lock()
63 gate := c.recoveryGate
64 c.mu.Unlock()
65 if gate != nil {
66 gate.ObserveResult(context.Background(), agent.RecoveryObservation{
67 Tool: "bash", Verification: true, ErrSummary: "exit 1",
68 Args: json.RawMessage(`{"command":"go test"}`),
69 })
70 if err := recovery.SaveSnapshot(sessionPath, gate.Snapshot()); err != nil {
71 t.Fatalf("SaveSnapshot: %v", err)
72 }
73 }
74 if _, err := os.Stat(recovery.PathFor(sessionPath)); err != nil {
75 t.Fatalf("recovery sidecar: %v", err)
76 }
77 snap, err := recovery.LoadSnapshot(sessionPath)
78 if err != nil || len(snap.Tasks) == 0 {
79 t.Fatalf("LoadSnapshot: err=%v tasks=%d", err, len(snap.Tasks))
80 }
81
82 m := c.RecoveryMetrics()
83 if m.FailureEvents == 0 || m.HumanPrompts != 0 || m.HumanContinues != 0 {
84 t.Fatalf("metrics = %+v", m)
85 }
86 delta := c.DrainRecoveryMetrics()
87 if delta.FailureEvents == 0 || delta.HumanPrompts != 0 || delta.HumanContinues != 0 {
88 t.Fatalf("drained metrics = %+v", delta)
89 }
90 if next := c.DrainRecoveryMetrics(); next != (recovery.Metrics{}) {
91 t.Fatalf("second metrics drain = %+v, want zero delta", next)
92 }
93 }
94
94 lines GO