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