| 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 |