| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "reasonix/internal/agent" |
| 13 | "reasonix/internal/checkpoint" |
| 14 | "reasonix/internal/event" |
| 15 | "reasonix/internal/provider" |
| 16 | "reasonix/internal/tool" |
| 17 | ) |
| 18 | |
| 19 | type resultWritingRunner struct { |
| 20 | session *agent.Session |
| 21 | write func() error |
| 22 | started chan struct{} |
| 23 | wait bool |
| 24 | failure error |
| 25 | } |
| 26 | |
| 27 | func (r *resultWritingRunner) Run(ctx context.Context, input string) error { |
| 28 | r.session.Add(provider.Message{Role: provider.RoleUser, Content: input, CreatedAt: time.Now().UnixMilli()}) |
| 29 | if err := r.write(); err != nil { |
| 30 | return err |
| 31 | } |
| 32 | if r.started != nil { |
| 33 | close(r.started) |
| 34 | } |
| 35 | if r.wait { |
| 36 | <-ctx.Done() |
| 37 | return ctx.Err() |
| 38 | } |
| 39 | return r.failure |
| 40 | } |
| 41 | |
| 42 | func TestControllerFreezesTurnResultBeforeTerminalPublication(t *testing.T) { |
| 43 | for _, mode := range []string{"success", "error", "cancel"} { |
| 44 | t.Run(mode, func(t *testing.T) { |
| 45 | root, dir := t.TempDir(), t.TempDir() |
| 46 | path := filepath.Join(root, "file.txt") |
| 47 | if err := os.WriteFile(path, []byte("user dirty\nold\n"), 0644); err != nil { |
| 48 | t.Fatal(err) |
| 49 | } |
| 50 | session := agent.NewSession("system") |
| 51 | runner := &resultWritingRunner{session: session, started: make(chan struct{}), wait: mode == "cancel"} |
| 52 | if mode == "error" { |
| 53 | runner.failure = errors.New("provider failed after write") |
| 54 | } |
| 55 | executor := agent.New(nil, tool.NewRegistry(), session, agent.Options{}, event.Discard) |
| 56 | events := make(chan event.Event, 2) |
| 57 | releaseTerminal := make(chan struct{}) |
| 58 | var cancelDone chan struct{} |
| 59 | c := newOwnedTestController(t, Options{Runner: runner, Executor: executor, WorkspaceRoot: root, SessionDir: dir, SessionPath: filepath.Join(dir, "session.jsonl"), Sink: event.FuncSink(func(e event.Event) { |
| 60 | if e.Kind == event.TurnDone { |
| 61 | events <- e |
| 62 | // Keep publication open while the test inspects the frozen result. |
| 63 | <-releaseTerminal |
| 64 | } |
| 65 | })}) |
| 66 | t.Cleanup(func() { |
| 67 | defer c.Close() |
| 68 | close(releaseTerminal) |
| 69 | if cancelDone != nil { |
| 70 | <-cancelDone |
| 71 | } |
| 72 | c.Cancel() |
| 73 | // TurnDone delivery precedes the ledger projection acknowledgement. |
| 74 | waitIdle(t, c) |
| 75 | c.autosaveWG.Wait() |
| 76 | }) |
| 77 | runner.write = func() error { |
| 78 | store := c.checkpoints.storeRef() |
| 79 | store.CaptureBefore("file.txt", checkpoint.CaptureBeforeOpts{}) |
| 80 | if err := os.WriteFile(path, []byte("user dirty\nnew\n"), 0644); err != nil { |
| 81 | return err |
| 82 | } |
| 83 | store.CaptureAfter("file.txt", checkpoint.CaptureAfterOpts{}) |
| 84 | return nil |
| 85 | } |
| 86 | c.Send("update file") |
| 87 | select { |
| 88 | case <-runner.started: |
| 89 | case <-time.After(5 * time.Second): |
| 90 | t.Fatal("runner not started") |
| 91 | } |
| 92 | if mode == "cancel" { |
| 93 | // Cancel can wait behind the held terminal event on the sink lane. |
| 94 | cancelDone = make(chan struct{}) |
| 95 | go func() { |
| 96 | c.Cancel() |
| 97 | close(cancelDone) |
| 98 | }() |
| 99 | } |
| 100 | done := receiveCheckpointTurnDone(t, events) |
| 101 | requireCheckpointTurn(t, done, 0) |
| 102 | if done.Receipt == nil || done.Receipt.Diff == nil { |
| 103 | t.Fatalf("terminal receipt missing: %+v", done) |
| 104 | } |
| 105 | if done.Receipt.AssessmentKind != "facts" || done.Receipt.Verdict != "unknown" { |
| 106 | t.Fatalf("checkpoint-only result became a quality assessment: %+v", done.Receipt) |
| 107 | } |
| 108 | diff := done.Receipt.Diff |
| 109 | if diff.Coverage != "complete" || diff.Added != 1 || diff.Removed != 1 || len(diff.Files) != 1 || diff.Files[0].Patch != "" { |
| 110 | t.Fatalf("summary: %+v", diff) |
| 111 | } |
| 112 | if mode == "cancel" && !done.Receipt.Interrupted { |
| 113 | t.Fatal("cancel lost receipt interruption") |
| 114 | } |
| 115 | if err := os.WriteFile(path, []byte("external later write\n"), 0644); err != nil { |
| 116 | t.Fatal(err) |
| 117 | } |
| 118 | frozen := c.CheckpointTurnChanges(0) |
| 119 | if !strings.Contains(frozen.Files[0].Patch, "+new") || strings.Contains(frozen.Files[0].Patch, "external later") { |
| 120 | t.Fatalf("result recomputed: %+v", frozen) |
| 121 | } |
| 122 | }) |
| 123 | } |
| 124 | } |
| 125 |