返回 DeepSeek-Reasonix
turn_runtime_api_test.go
根目录 / desktop / turn_runtime_api_test.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "path/filepath"
7 "strings"
8 "sync"
9 "testing"
10 "time"
11
12 "reasonix/internal/control"
13 "reasonix/internal/event"
14 )
15
16 type exactTurnRunner struct {
17 once sync.Once
18 started chan struct{}
19 }
20
21 func (r *exactTurnRunner) Run(ctx context.Context, _ string) error {
22 r.once.Do(func() { close(r.started) })
23 <-ctx.Done()
24 return ctx.Err()
25 }
26
27 func cleanupExactTurnController(t *testing.T, ctrl *control.Controller) {
28 t.Helper()
29 t.Cleanup(func() {
30 ctrl.Close()
31 select {
32 case <-ctrl.Closed():
33 case <-time.After(5 * time.Second):
34 t.Error("Controller teardown did not finish before removing its storage")
35 }
36 })
37 }
38
39 func TestTurnRuntimeAPIRoutesStopAnswerAndReplayByExactTurn(t *testing.T) {
40 dir := t.TempDir()
41 runner := &exactTurnRunner{started: make(chan struct{})}
42 sink := &tabEventSink{tabID: "tab", ctx: context.Background()}
43 terminal := make(chan event.Event, 1)
44 sink.SetBotSink(event.FuncSink(func(e event.Event) {
45 if e.Kind == event.TurnDone {
46 terminal <- e
47 }
48 }))
49 ctrl := control.New(control.Options{
50 Runner: runner, Sink: sink, SessionDir: dir,
51 SessionPath: filepath.Join(dir, "session.jsonl"),
52 })
53 cleanupExactTurnController(t, ctrl)
54 tab := &WorkspaceTab{ID: "tab", Scope: "global", Ready: true, Ctrl: ctrl, sink: sink}
55 app := &App{tabs: map[string]*WorkspaceTab{tab.ID: tab}, activeTabID: tab.ID}
56 sink.app = app
57
58 start, err := app.StartTurnForTab(tab.ID, "hold this turn", "submission-1")
59 if err != nil {
60 t.Fatalf("StartTurnForTab: %v", err)
61 }
62 if !strings.HasPrefix(start.TurnID, "turn_") || start.SubmissionID != "submission-1" {
63 t.Fatalf("start receipt = %+v, want stable turn and submission ids", start)
64 }
65 select {
66 case <-runner.started:
67 case <-time.After(5 * time.Second):
68 t.Fatal("turn runner did not start")
69 }
70
71 if err := app.AnswerPromptForTab(tab.ID, "turn_stale", "prompt-1", nil); err == nil {
72 t.Fatal("stale turn id answered an active turn prompt")
73 }
74 if _, err := app.EnqueueInboxSteerForTurn(tab.ID, "turn_stale", "late steer", "late steer", ""); err == nil {
75 t.Fatal("stale turn id steered the active turn")
76 }
77 if err := app.AnswerPromptForTab(tab.ID, start.TurnID, "already-answered", nil); err != nil {
78 t.Fatalf("same-turn duplicate/unknown answer should be idempotent: %v", err)
79 }
80
81 before, err := app.TurnEventsForTab(tab.ID, 0)
82 if err != nil {
83 t.Fatalf("TurnEventsForTab before cancel: %v", err)
84 }
85 if len(before.Events) == 0 || before.Events[0].Status != event.TurnQueued {
86 t.Fatalf("events before cancel = %+v, want durable queued prefix", before)
87 }
88 if err := app.InterruptTurnForTab(tab.ID, start.TurnID); err != nil {
89 t.Fatalf("InterruptTurnForTab: %v", err)
90 }
91 select {
92 case done := <-terminal:
93 if done.Status != event.TurnInterrupted {
94 t.Fatalf("terminal status = %q, want interrupted", done.Status)
95 }
96 case <-time.After(5 * time.Second):
97 t.Fatal("turn did not reach terminal state after exact interrupt")
98 }
99 after, err := app.TurnEventsForTab(tab.ID, before.Events[len(before.Events)-1].Sequence)
100 if err != nil {
101 t.Fatalf("TurnEventsForTab after cancel: %v", err)
102 }
103 if len(after.Events) == 0 || after.Events[len(after.Events)-1].Status != event.TurnInterrupted {
104 t.Fatalf("events after cancel = %+v, want non-nil interrupted suffix", after)
105 }
106 empty, err := app.TurnEventsForTab(tab.ID, after.Events[len(after.Events)-1].Sequence)
107 if err != nil {
108 t.Fatalf("empty replay: %v", err)
109 }
110 if empty.Events == nil || len(empty.Events) != 0 {
111 t.Fatalf("empty replay events = %#v, want []", empty.Events)
112 }
113 }
114
115 // A Stop button rendered for an earlier turn must still stop the turn that is
116 // running now; only an idle tab is reported back, with a stable code.
117 func TestInterruptTurnForTabStopsActiveWorkDespiteStaleTurnID(t *testing.T) {
118 dir := t.TempDir()
119 runner := &exactTurnRunner{started: make(chan struct{})}
120 sink := &tabEventSink{tabID: "tab", ctx: context.Background()}
121 terminal := make(chan event.Event, 1)
122 sink.SetBotSink(event.FuncSink(func(e event.Event) {
123 if e.Kind == event.TurnDone {
124 terminal <- e
125 }
126 }))
127 ctrl := control.New(control.Options{
128 Runner: runner, Sink: sink, SessionDir: dir,
129 SessionPath: filepath.Join(dir, "session.jsonl"),
130 })
131 cleanupExactTurnController(t, ctrl)
132 tab := &WorkspaceTab{ID: "tab", Scope: "global", Ready: true, Ctrl: ctrl, sink: sink}
133 app := &App{tabs: map[string]*WorkspaceTab{tab.ID: tab}, activeTabID: tab.ID}
134 sink.app = app
135
136 if err := app.InterruptTurnForTab(tab.ID, "turn_none"); err != nil {
137 t.Fatalf("idle stop = %v, want idempotent success", err)
138 }
139 if receipt, err := app.CancelSessionForTab(tab.ID); err != nil || !receipt.Accepted || !receipt.AlreadyIdle {
140 t.Fatalf("idle cancel receipt = %+v, %v", receipt, err)
141 }
142
143 if _, err := app.StartTurnForTab(tab.ID, "hold this turn", "submission-1"); err != nil {
144 t.Fatalf("StartTurnForTab: %v", err)
145 }
146 select {
147 case <-runner.started:
148 case <-time.After(5 * time.Second):
149 t.Fatal("turn runner did not start")
150 }
151 if err := app.InterruptTurnForTab(tab.ID, "turn_stale"); err != nil {
152 t.Fatalf("stale-id stop = %v, want the active turn interrupted", err)
153 }
154 select {
155 case done := <-terminal:
156 if done.Status != event.TurnInterrupted {
157 t.Fatalf("terminal status = %q, want interrupted", done.Status)
158 }
159 case <-time.After(5 * time.Second):
160 t.Fatal("turn did not reach terminal state after stale-id stop")
161 }
162 }
163
164 func TestStartTurnForTabReturnsManagementDispositionWithoutTurnID(t *testing.T) {
165 dir := t.TempDir()
166 sink := &tabEventSink{tabID: "tab", ctx: context.Background()}
167 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, "session.jsonl"), Sink: sink})
168 cleanupExactTurnController(t, ctrl)
169 tab := &WorkspaceTab{ID: "tab", Scope: "global", Ready: true, Ctrl: ctrl, sink: sink}
170 app := &App{tabs: map[string]*WorkspaceTab{tab.ID: tab}, activeTabID: tab.ID}
171 sink.app = app
172
173 start, err := app.StartTurnForTab(tab.ID, "/context", "submission-management")
174 if err != nil {
175 t.Fatalf("StartTurnForTab management command: %v", err)
176 }
177 if start.Disposition != control.SubmitManagementHandled || start.TurnID != "" {
178 t.Fatalf("management receipt = %+v, want management_handled without turn id", start)
179 }
180 replay, err := app.TurnEventsForTab(tab.ID, 0)
181 if err != nil {
182 t.Fatalf("TurnEventsForTab: %v", err)
183 }
184 if len(replay.Events) != 0 {
185 t.Fatalf("management command created durable turn events: %+v", replay.Events)
186 }
187 }
188
189 func TestStartTurnForTabRejectsManagementDuringActiveTurn(t *testing.T) {
190 dir := t.TempDir()
191 runner := &exactTurnRunner{started: make(chan struct{})}
192 sink := &tabEventSink{tabID: "tab", ctx: context.Background()}
193 terminal := make(chan event.Event, 1)
194 sink.SetBotSink(event.FuncSink(func(e event.Event) {
195 if e.Kind == event.TurnDone {
196 terminal <- e
197 }
198 }))
199 ctrl := control.New(control.Options{
200 Runner: runner, Sink: sink, SessionDir: dir,
201 SessionPath: filepath.Join(dir, "session.jsonl"),
202 })
203 cleanupExactTurnController(t, ctrl)
204 tab := &WorkspaceTab{ID: "tab", Scope: "global", Ready: true, Ctrl: ctrl, sink: sink}
205 app := &App{tabs: map[string]*WorkspaceTab{tab.ID: tab}, activeTabID: tab.ID}
206 sink.app = app
207
208 start, err := app.StartTurnForTab(tab.ID, "hold this turn", "submission-active")
209 if err != nil {
210 t.Fatalf("StartTurnForTab active turn: %v", err)
211 }
212 select {
213 case <-runner.started:
214 case <-time.After(5 * time.Second):
215 t.Fatal("turn runner did not start")
216 }
217 if _, err := app.StartTurnForTab(tab.ID, "/context", "submission-management"); !errors.Is(err, control.ErrTurnRunning) {
218 t.Fatalf("management command during active turn = %v, want ErrTurnRunning", err)
219 }
220 status := ctrl.RuntimeStatus()
221 if !status.Running || status.TurnID != start.TurnID {
222 t.Fatalf("active turn changed after rejected management command: %+v", status)
223 }
224 ctrl.Cancel()
225 select {
226 case <-terminal:
227 case <-time.After(5 * time.Second):
228 t.Fatal("turn did not finish after cancellation")
229 }
230 }
231
231 lines GO