返回 DeepSeek-Reasonix
turn_lifecycle_test.go
根目录 / internal / cli / turn_lifecycle_test.go
1 package cli
2
3 import (
4 "errors"
5 "testing"
6 "time"
7
8 "reasonix/internal/control"
9 "reasonix/internal/event"
10 "reasonix/internal/provider"
11 "reasonix/internal/sessioninbox"
12 )
13
14 type runningQueueController struct {
15 control.SessionAPI
16 req control.InboxRequest
17 err error
18 calls int
19 }
20
21 func (c *runningQueueController) Running() bool { return true }
22
23 func (c *runningQueueController) TryEnqueueFollowup(req control.InboxRequest) (sessioninbox.InboxReceipt, error) {
24 c.calls++
25 c.req = req
26 return sessioninbox.InboxReceipt{ItemID: "queued-item"}, c.err
27 }
28
29 // TestControllerDispatchedTurnStartedEntersRunning pins the #9575 fix: when
30 // the controller auto-dispatches a queued follow-up, the TurnStarted event
31 // flips the composer into running state so an Enter queues instead of racing
32 // the dispatched turn, and the elapsed-tick chain re-arms.
33 func TestControllerDispatchedTurnStartedEntersRunning(t *testing.T) {
34 ctrl := newOwnedTestController(t, control.Options{})
35 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
36 if m.state != tuiIdle {
37 t.Fatalf("fresh TUI state = %v, want idle", m.state)
38 }
39
40 next, _ := m.Update(agentEventMsg(event.Event{Kind: event.TurnStarted}))
41 m2 := next.(chatTUI)
42 if m2.state != tuiRunning {
43 t.Fatalf("dispatched TurnStarted left composer idle: %v", m2.state)
44 }
45
46 // A second TurnStarted while already running must not restart the clock.
47 past := time.Now().Add(-5 * time.Minute)
48 m2.runStart = past
49 generation := m2.elapsedTickGeneration
50 next, _ = m2.Update(agentEventMsg(event.Event{Kind: event.TurnStarted}))
51 m3 := next.(chatTUI)
52 if m3.runStart != past {
53 t.Fatal("redundant TurnStarted restarted the elapsed timer")
54 }
55 if m3.elapsedTickGeneration != generation {
56 t.Fatal("redundant TurnStarted started another elapsed-tick chain")
57 }
58 }
59
60 func TestDrainedLifecyclePreservesOrder(t *testing.T) {
61 tests := []struct {
62 name string
63 initial tuiState
64 first event.Kind
65 buffered event.Kind
66 want tuiState
67 }{
68 {name: "next turn starts after done", initial: tuiRunning, first: event.TurnDone, buffered: event.TurnStarted, want: tuiRunning},
69 {name: "fast turn finishes after start", initial: tuiIdle, first: event.TurnStarted, buffered: event.TurnDone, want: tuiIdle},
70 }
71 for _, tt := range tests {
72 t.Run(tt.name, func(t *testing.T) {
73 events := make(chan event.Event, 1)
74 events <- event.Event{Kind: tt.buffered}
75 m := newChatTUI(newOwnedTestController(t, control.Options{}), "", events, 80)
76 m.state = tt.initial
77
78 next, _ := m.Update(agentEventMsg(event.Event{Kind: tt.first}))
79 if got := next.(chatTUI).state; got != tt.want {
80 t.Fatalf("final state = %v, want %v", got, tt.want)
81 }
82 })
83 }
84 }
85
86 func TestDrainedTurnStartResetsBeforeNewUsage(t *testing.T) {
87 events := make(chan event.Event, 2)
88 events <- event.Event{Kind: event.TurnStarted}
89 events <- event.Event{Kind: event.Usage, Usage: &provider.Usage{CompletionTokens: 3}}
90 m := newChatTUI(newOwnedTestController(t, control.Options{}), "", events, 80)
91 m.state = tuiRunning
92 m.turnTokens = 100
93
94 next, _ := m.Update(agentEventMsg(event.Event{Kind: event.TurnDone}))
95 m2 := next.(chatTUI)
96 if m2.state != tuiRunning {
97 t.Fatalf("queued turn state = %v, want running", m2.state)
98 }
99 if m2.turnTokens != 3 {
100 t.Fatalf("queued turn tokens = %d, want 3", m2.turnTokens)
101 }
102 }
103
104 func TestElapsedTickRejectsPriorTurnGeneration(t *testing.T) {
105 m := newChatTUI(newOwnedTestController(t, control.Options{}), "", make(chan event.Event, 1), 80)
106 m.state = tuiRunning
107 m.runStart = time.Now().Add(-30 * time.Second)
108 m.elapsed = 7
109 m.elapsedTickGeneration = 2
110
111 next, cmd := m.Update(elapsedTickMsg{generation: 1})
112 m2 := next.(chatTUI)
113 if m2.elapsed != 7 {
114 t.Fatalf("stale tick changed elapsed = %d, want 7", m2.elapsed)
115 }
116 if cmd != nil {
117 t.Fatal("stale tick scheduled another timer")
118 }
119 next, _ = m2.Update(elapsedTickMsg{generation: 2})
120 if got := next.(chatTUI).elapsed; got < 29 {
121 t.Fatalf("current tick left elapsed = %d, want at least 29", got)
122 }
123 }
124
125 func TestStartControllerTurnQueuesThroughSessionPort(t *testing.T) {
126 ctrl := &runningQueueController{SessionAPI: newOwnedTestController(t, control.Options{})}
127 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
128 m.input.SetValue("next draft")
129 m.pastedBlocks = []pastedBlock{{label: "old paste"}, {label: "next paste"}}
130 started := false
131
132 cmd := m.startControllerTurn("expanded", "old paste", func(control.SessionAPI) { started = true })
133 if cmd != nil || started {
134 t.Fatalf("running controller started a competing turn: cmd=%v started=%v", cmd != nil, started)
135 }
136 if ctrl.req.Display != "expanded" || ctrl.req.Raw != "expanded" || ctrl.req.Submit != "expanded" {
137 t.Fatalf("queued request = %+v, want expanded display/raw/submit", ctrl.req)
138 }
139 if got := m.input.Value(); got != "next draft" {
140 t.Fatalf("successful queue changed the next draft to %q", got)
141 }
142 if len(m.pastedBlocks) != 1 || m.pastedBlocks[0].label != "next paste" {
143 t.Fatalf("successful queue cleared unrelated paste state: %+v", m.pastedBlocks)
144 }
145 }
146
147 func TestStartControllerTurnRejectsInputDuringRemoteReclaim(t *testing.T) {
148 ctrl := &runningQueueController{SessionAPI: newOwnedTestController(t, control.Options{})}
149 takeover := newCLITakeoverManager(nil, nil)
150 takeover.reclaiming.Store(true)
151 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
152 m.takeover = takeover
153 started := false
154
155 cmd := m.startControllerTurn("expanded", "draft", func(control.SessionAPI) { started = true })
156 if cmd != nil || started {
157 t.Fatalf("remote reclaim started a turn: cmd=%v started=%v", cmd != nil, started)
158 }
159 if ctrl.calls != 0 {
160 t.Fatalf("remote reclaim queued input %d time(s): %+v", ctrl.calls, ctrl.req)
161 }
162 if m.state != tuiIdle || m.bubblePending {
163 t.Fatalf("remote reclaim changed turn state: state=%v bubblePending=%v", m.state, m.bubblePending)
164 }
165 }
166
167 func TestStartTurnWithRawQueuesMaterializablePrompt(t *testing.T) {
168 ctrl := &runningQueueController{SessionAPI: newOwnedTestController(t, control.Options{})}
169 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
170
171 m.startTurnWithRaw("resolved context", "typed @file", "typed @file", "typed @file")
172 if ctrl.req.Display != "typed @file" || ctrl.req.Raw != "typed @file" || ctrl.req.Submit != "typed @file" {
173 t.Fatalf("queued request = %+v, want unresolved prompt for durable reference capture", ctrl.req)
174 }
175 }
176
177 func TestStartControllerTurnRestoresComposerOnQueueFailure(t *testing.T) {
178 wantErr := errors.New("queue unavailable")
179 ctrl := &runningQueueController{SessionAPI: newOwnedTestController(t, control.Options{}), err: wantErr}
180 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
181
182 started := false
183 cmd := m.startControllerTurn("expanded", "draft", func(control.SessionAPI) { started = true })
184 if cmd != nil || started {
185 t.Fatalf("failed queue started a competing turn: cmd=%v started=%v", cmd != nil, started)
186 }
187 if got := m.input.Value(); got != "draft" {
188 t.Fatalf("failed queue restored composer = %q, want draft", got)
189 }
190 }
191
192 func TestStartControllerTurnQueueFailurePreservesNextDraft(t *testing.T) {
193 ctrl := &runningQueueController{SessionAPI: newOwnedTestController(t, control.Options{}), err: errors.New("queue unavailable")}
194 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
195 m.input.SetValue("next draft")
196
197 m.startControllerTurn("expanded", "failed submission", func(control.SessionAPI) {})
198 if got := m.input.Value(); got != "next draft" {
199 t.Fatalf("failed async queue overwrote the next draft with %q", got)
200 }
201 }
202
202 lines GO