返回 DeepSeek-Reasonix
maintenance_lifecycle_test.go
根目录 / internal / cli / maintenance_lifecycle_test.go
1 package cli
2
3 import (
4 "context"
5 "strings"
6 "testing"
7 "time"
8
9 tea "charm.land/bubbletea/v2"
10
11 "reasonix/internal/agent"
12 "reasonix/internal/control"
13 "reasonix/internal/event"
14 "reasonix/internal/provider"
15 )
16
17 type maintenanceCLIController struct {
18 control.SessionAPI
19 cancelCalls int
20 submissions []string
21 result control.SubmitResult
22 }
23
24 type cliBlockingSummaryProvider struct {
25 started chan struct{}
26 cancelled chan struct{}
27 }
28
29 func (p *cliBlockingSummaryProvider) Name() string { return "cli-blocking-summary" }
30
31 func (p *cliBlockingSummaryProvider) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
32 close(p.started)
33 <-ctx.Done()
34 close(p.cancelled)
35 return nil, ctx.Err()
36 }
37
38 func (c *maintenanceCLIController) Cancel() { c.cancelCalls++ }
39
40 func (c *maintenanceCLIController) SubmitDisplayWithResult(display, input string) control.SubmitResult {
41 c.submissions = append(c.submissions, display+"\x00"+input)
42 return c.result
43 }
44
45 func maintenanceEvent(id, activity, status string) agentEventMsg {
46 return agentEventMsg(event.Event{
47 Kind: event.SessionOperation,
48 SessionOperation: &event.SessionOperationInfo{
49 OperationID: id,
50 Kind: "compact",
51 Activity: activity,
52 Status: status,
53 },
54 })
55 }
56
57 func TestSessionOperationOwnsMaintenanceWithoutStartingTurn(t *testing.T) {
58 ctrl := &maintenanceCLIController{SessionAPI: newOwnedTestController(t, control.Options{})}
59 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
60
61 next, _ := m.Update(maintenanceEvent("op-1", "running", "running"))
62 m = next.(chatTUI)
63 if m.state != tuiIdle {
64 t.Fatalf("maintenance changed ordinary turn state = %v, want idle", m.state)
65 }
66 if m.maintenance == nil || m.maintenance.OperationID != "op-1" || m.maintenance.Activity != "running" {
67 t.Fatalf("maintenance state = %+v, want running op-1", m.maintenance)
68 }
69 if !m.runStart.IsZero() || m.elapsedTickGeneration != 0 {
70 t.Fatalf("maintenance started ordinary turn timing: start=%v generation=%d", m.runStart, m.elapsedTickGeneration)
71 }
72
73 next, _ = m.Update(maintenanceEvent("op-1", "finalizing", "finalizing"))
74 m = next.(chatTUI)
75 if m.maintenance == nil || m.maintenance.Activity != "finalizing" {
76 t.Fatalf("finalizing operation state = %+v", m.maintenance)
77 }
78
79 next, _ = m.Update(maintenanceEvent("op-1", "finalizing", "completed"))
80 m = next.(chatTUI)
81 if m.maintenance != nil {
82 t.Fatalf("terminal operation left maintenance active: %+v", m.maintenance)
83 }
84 }
85
86 func TestTerminalSessionOperationCannotRegressToCancelling(t *testing.T) {
87 ctrl := &maintenanceCLIController{SessionAPI: newOwnedTestController(t, control.Options{})}
88 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
89
90 next, _ := m.Update(maintenanceEvent("op-1", "running", "running"))
91 m = next.(chatTUI)
92 next, _ = m.Update(maintenanceEvent("op-1", "finalizing", "completed"))
93 m = next.(chatTUI)
94 terminalCard := m.transcript[m.maintenanceTranscriptIdx]
95
96 next, _ = m.Update(maintenanceEvent("op-1", "cancelling", "cancelling"))
97 m = next.(chatTUI)
98 if m.maintenance != nil {
99 t.Fatalf("late cancelling event reactivated maintenance: %+v", m.maintenance)
100 }
101 if got := m.transcript[m.maintenanceTranscriptIdx]; got != terminalCard {
102 t.Fatalf("late cancelling event replaced terminal card:\n%s", got)
103 }
104 }
105
106 func TestSessionOperationRevisionRejectsOlderProgress(t *testing.T) {
107 ctrl := &maintenanceCLIController{SessionAPI: newOwnedTestController(t, control.Options{})}
108 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
109
110 newer := event.Event{Kind: event.SessionOperation, SessionOperation: &event.SessionOperationInfo{
111 OperationID: "op-1", OperationRevision: 3, RuntimeEpoch: "epoch-1",
112 Kind: "compact", Activity: "finalizing", Status: "finalizing", Detail: "keep",
113 }}
114 older := event.Event{Kind: event.SessionOperation, SessionOperation: &event.SessionOperationInfo{
115 OperationID: "op-1", OperationRevision: 2, RuntimeEpoch: "epoch-1",
116 Kind: "compact", Activity: "cancelling", Status: "cancelling",
117 }}
118 next, _ := m.Update(agentEventMsg(newer))
119 m = next.(chatTUI)
120 next, _ = m.Update(agentEventMsg(older))
121 m = next.(chatTUI)
122
123 if m.maintenance == nil || m.maintenance.OperationRevision != 3 || m.maintenance.Activity != "finalizing" {
124 t.Fatalf("older progress replaced newer maintenance state: %+v", m.maintenance)
125 }
126 if m.maintenance.Detail != "keep" {
127 t.Fatalf("older progress cleared detail: %+v", m.maintenance)
128 }
129 }
130
131 func TestSessionOperationUpdatesOneCompactionCard(t *testing.T) {
132 ctrl := &maintenanceCLIController{SessionAPI: newOwnedTestController(t, control.Options{})}
133 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
134
135 next, _ := m.Update(maintenanceEvent("op-1", "running", "running"))
136 m = next.(chatTUI)
137 cardCount := len(m.transcript)
138 cardIndex := m.maintenanceTranscriptIdx
139
140 next, _ = m.Update(maintenanceEvent("op-1", "cancelling", "cancelling"))
141 m = next.(chatTUI)
142 if len(m.transcript) != cardCount || m.maintenanceTranscriptIdx != cardIndex {
143 t.Fatalf("cancelling inserted another card: count=%d index=%d", len(m.transcript), m.maintenanceTranscriptIdx)
144 }
145
146 done := event.Event{Kind: event.SessionOperation, SessionOperation: &event.SessionOperationInfo{
147 OperationID: "op-1", Kind: "compact", Activity: "finalizing", Status: "completed",
148 Applied: true, InputTokens: 12000, ResultTokens: 3000, Messages: 6, Summary: "retained summary",
149 }}
150 next, _ = m.Update(agentEventMsg(done))
151 m = next.(chatTUI)
152 if len(m.transcript) != cardCount || m.maintenanceTranscriptIdx != cardIndex {
153 t.Fatalf("completion inserted another card: count=%d index=%d", len(m.transcript), m.maintenanceTranscriptIdx)
154 }
155 card := m.transcript[cardIndex]
156 for _, want := range []string{"retained summary", "12.0K", "3.0K"} {
157 if !strings.Contains(card, want) {
158 t.Fatalf("completed card missing %q:\n%s", want, card)
159 }
160 }
161 }
162
163 func TestEscCancelsMaintenanceOnceAndPreservesDraft(t *testing.T) {
164 ctrl := &maintenanceCLIController{SessionAPI: newOwnedTestController(t, control.Options{})}
165 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
166 m.input.SetValue("keep this draft")
167 m.pastedBlocks = []pastedBlock{{label: "[paste #1]", text: "payload"}}
168
169 next, _ := m.Update(maintenanceEvent("op-1", "running", "running"))
170 m = next.(chatTUI)
171 next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
172 m = next.(chatTUI)
173
174 if ctrl.cancelCalls != 1 {
175 t.Fatalf("Esc cancel calls = %d, want 1", ctrl.cancelCalls)
176 }
177 if got := m.input.Value(); got != "keep this draft" {
178 t.Fatalf("Esc changed maintenance draft to %q", got)
179 }
180 if len(m.pastedBlocks) != 1 || m.pastedBlocks[0].text != "payload" {
181 t.Fatalf("Esc changed maintenance paste state: %+v", m.pastedBlocks)
182 }
183 if m.maintenance == nil || m.maintenance.Activity != "cancelling" {
184 t.Fatalf("Esc maintenance state = %+v, want cancelling", m.maintenance)
185 }
186
187 next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
188 m = next.(chatTUI)
189 if ctrl.cancelCalls != 1 {
190 t.Fatalf("repeated Esc cancel calls = %d, want idempotent 1", ctrl.cancelCalls)
191 }
192 if got := m.input.Value(); got != "keep this draft" {
193 t.Fatalf("repeated Esc changed maintenance draft to %q", got)
194 }
195 }
196
197 func TestCompactSlashUsesRegisteredManagementSubmission(t *testing.T) {
198 ctrl := &maintenanceCLIController{
199 SessionAPI: newOwnedTestController(t, control.Options{}),
200 result: control.SubmitResult{
201 Disposition: control.SubmitManagementHandled,
202 OperationID: "op-registered",
203 },
204 }
205 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
206
207 if cmd := m.runSlashCommand("/compact retain tests"); cmd != nil {
208 t.Fatal("registered /compact should not return a long-running TUI command")
209 }
210 if len(ctrl.submissions) != 1 || ctrl.submissions[0] != "/compact retain tests\x00/compact retain tests" {
211 t.Fatalf("management submissions = %#v", ctrl.submissions)
212 }
213 if m.maintenance == nil || m.maintenance.OperationID != "op-registered" {
214 t.Fatalf("registered maintenance state = %+v", m.maintenance)
215 }
216 }
217
218 func TestCompactCommandEscCancelsRealMaintenanceAndKeepsDraft(t *testing.T) {
219 providerStub := &cliBlockingSummaryProvider{
220 started: make(chan struct{}),
221 cancelled: make(chan struct{}),
222 }
223 sess := agent.NewSession("sys")
224 for range 8 {
225 sess.Add(provider.Message{Role: provider.RoleUser, Content: strings.Repeat("question ", 300)})
226 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("answer ", 300)})
227 }
228 events := make(chan event.Event, 128)
229 sink := event.FuncSink(func(e event.Event) { events <- e })
230 exec := agent.New(providerStub, nil, sess, agent.Options{ContextWindow: 32000}, sink)
231 ctrl := newOwnedTestController(t, control.Options{Executor: exec, Sink: sink})
232 m := newChatTUI(ctrl, "", events, 80)
233
234 if cmd := m.runSlashCommand("/compact retain tests"); cmd != nil {
235 t.Fatal("registered /compact returned a blocking command")
236 }
237 select {
238 case <-providerStub.started:
239 case <-time.After(3 * time.Second):
240 t.Fatal("summary provider did not start")
241 }
242
243 deadline := time.After(3 * time.Second)
244 for len(m.maintenanceLatest) == 0 {
245 select {
246 case e := <-events:
247 next, _ := m.Update(agentEventMsg(e))
248 m = next.(chatTUI)
249 case <-deadline:
250 t.Fatal("SessionOperation start event was not dispatched to the TUI")
251 }
252 }
253 m.input.SetValue("draft queued during compaction")
254 next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
255 m = next.(chatTUI)
256
257 select {
258 case <-providerStub.cancelled:
259 case <-time.After(3 * time.Second):
260 t.Fatal("Esc did not cancel the real summary request")
261 }
262 if got := m.input.Value(); got != "draft queued during compaction" {
263 t.Fatalf("Esc changed draft to %q", got)
264 }
265 if m.state != tuiIdle {
266 t.Fatalf("maintenance changed ordinary turn state to %v", m.state)
267 }
268 }
269
269 lines GO