返回 DeepSeek-Reasonix
tabs_runtime_status_test.go
根目录 / desktop / tabs_runtime_status_test.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "io"
7 "os"
8 "strings"
9 "testing"
10 "time"
11
12 "reasonix/internal/agent"
13 "reasonix/internal/control"
14 "reasonix/internal/event"
15 "reasonix/internal/jobs"
16 )
17
18 func TestProjectTreeShowsDetachedRuntimeStatus(t *testing.T) {
19 isolateDesktopUserDirs(t)
20 dir := desktopSessionDir(globalTabWorkspaceRoot())
21 if err := os.MkdirAll(dir, 0o755); err != nil {
22 t.Fatalf("mkdir sessions: %v", err)
23 }
24 topicID := "topic_detached_status"
25 topicTitle := "Detached status"
26 if err := setTopicTitle("", topicID, topicTitle); err != nil {
27 t.Fatalf("set topic title: %v", err)
28 }
29 path := writeTopicSessionWithPrompt(t, dir, "detached.jsonl", topicID, topicTitle, "", "detached prompt", time.Now())
30
31 app := NewApp()
32 sink := &tabEventSink{tabID: "detached", app: app}
33 runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
34 ctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: "detached", Sink: sink})
35 defer ctrl.Close()
36 app.detachedSessions[sessionRuntimeKey(path)] = &WorkspaceTab{
37 ID: "detached",
38 Scope: "global",
39 WorkspaceRoot: globalTabWorkspaceRoot(),
40 TopicID: topicID,
41 TopicTitle: topicTitle,
42 SessionPath: path,
43 Ctrl: ctrl,
44 Ready: true,
45 sink: sink,
46 disabledMCP: map[string]ServerView{},
47 }
48
49 ctrl.Submit("keep detached runtime running")
50 <-runner.started
51 nodes := app.ListProjectTree()
52 if len(nodes) != 1 || len(nodes[0].Children) != 1 {
53 t.Fatalf("project tree = %#v, want one global topic", nodes)
54 }
55 topic := nodes[0].Children[0]
56 if topic.TopicID != topicID || topic.Status != topicStatusThinking || !topic.Running {
57 t.Fatalf("detached topic status = %+v, want thinking/running for %q", topic, topicID)
58 }
59
60 close(runner.release)
61 waitNotRunning(t, ctrl)
62 }
63
64 func TestProjectTreeSplitsMultipleRuntimeSessionsInSameTopic(t *testing.T) {
65 isolateDesktopUserDirs(t)
66 dir := desktopSessionDir(globalTabWorkspaceRoot())
67 if err := os.MkdirAll(dir, 0o755); err != nil {
68 t.Fatalf("mkdir sessions: %v", err)
69 }
70 topicID := "topic_multi_runtime_status"
71 topicTitle := "Multi runtime status"
72 if err := setTopicTitle("", topicID, topicTitle); err != nil {
73 t.Fatalf("set topic title: %v", err)
74 }
75 sessionA := writeTopicSessionWithPrompt(t, dir, "session-a.jsonl", topicID, topicTitle, "", "session A prompt", time.Now().Add(-time.Hour))
76 sessionB := writeTopicSessionWithPrompt(t, dir, "session-b.jsonl", topicID, topicTitle, "", "session B prompt", time.Now())
77
78 app := NewApp()
79 runnerA := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
80 runnerB := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
81 asks := make(chan struct{}, 1)
82 ctrlA := control.New(control.Options{Runner: runnerA, SessionDir: dir, SessionPath: sessionA, Label: "a", Sink: event.FuncSink(func(e event.Event) {
83 if e.Kind == event.AskRequest {
84 asks <- struct{}{}
85 }
86 })})
87 ctrlB := control.New(control.Options{Runner: runnerB, SessionDir: dir, SessionPath: sessionB, Label: "b", Sink: event.Discard})
88 defer ctrlA.Close()
89 defer ctrlB.Close()
90
91 detached := &WorkspaceTab{
92 ID: detachedRuntimeTabID(sessionRuntimeKey(sessionA)),
93 Scope: "global",
94 WorkspaceRoot: globalTabWorkspaceRoot(),
95 TopicID: topicID,
96 TopicTitle: topicTitle,
97 SessionPath: sessionA,
98 Ctrl: ctrlA,
99 Ready: true,
100 ActivityStatus: topicStatusWaitingConfirmation,
101 disabledMCP: map[string]ServerView{},
102 }
103 visible := &WorkspaceTab{
104 ID: "visible",
105 Scope: "global",
106 WorkspaceRoot: globalTabWorkspaceRoot(),
107 TopicID: topicID,
108 TopicTitle: topicTitle,
109 SessionPath: sessionB,
110 Ctrl: ctrlB,
111 Ready: true,
112 ActivityStatus: topicStatusThinking,
113 disabledMCP: map[string]ServerView{},
114 }
115 app.detachedSessions[sessionRuntimeKey(sessionA)] = detached
116 app.tabs[visible.ID] = visible
117 app.tabOrder = []string{visible.ID}
118 app.activeTabID = visible.ID
119
120 ctrlA.Submit("block A")
121 ctrlB.Submit("block B")
122 <-runnerA.started
123 <-runnerB.started
124 askCtx, cancelAsk := context.WithCancel(t.Context())
125 askDone := make(chan struct{})
126 go func() {
127 defer close(askDone)
128 _, _ = ctrlA.Ask(askCtx, []event.AskQuestion{{ID: "choice", Prompt: "Choose"}})
129 }()
130 defer func() { cancelAsk(); <-askDone }()
131 select {
132 case <-asks:
133 case <-time.After(3 * time.Second):
134 t.Fatal("waiting prompt was not committed")
135 }
136
137 nodes := app.ListProjectTree()
138 if len(nodes) != 1 || len(nodes[0].Children) != 2 {
139 t.Fatalf("project tree = %#v, want two independent global sessions", nodes)
140 }
141 statusByPath := map[string]string{}
142 for _, child := range nodes[0].Children {
143 if child.TopicID != topicID || len(child.Children) != 0 {
144 t.Fatalf("session projection = %#v, want flat independent row", child)
145 }
146 statusByPath[sessionRuntimeKey(child.SessionPath)] = child.Status
147 }
148 if statusByPath[sessionRuntimeKey(sessionA)] != topicStatusWaitingConfirmation {
149 t.Fatalf("session A status = %q, want waiting; sessions=%#v", statusByPath[sessionRuntimeKey(sessionA)], nodes[0].Children)
150 }
151 if statusByPath[sessionRuntimeKey(sessionB)] != topicStatusThinking {
152 t.Fatalf("session B status = %q, want thinking; sessions=%#v", statusByPath[sessionRuntimeKey(sessionB)], nodes[0].Children)
153 }
154
155 close(runnerA.release)
156 close(runnerB.release)
157 cancelAsk()
158 waitNotRunning(t, ctrlA)
159 waitNotRunning(t, ctrlB)
160 }
161
162 func TestProjectTreeShowsBackgroundJobStatus(t *testing.T) {
163 isolateDesktopUserDirs(t)
164 dir := desktopSessionDir(globalTabWorkspaceRoot())
165 if err := os.MkdirAll(dir, 0o755); err != nil {
166 t.Fatalf("mkdir sessions: %v", err)
167 }
168 topicID := "topic_background_job"
169 topicTitle := "Background job"
170 if err := setTopicTitle("", topicID, topicTitle); err != nil {
171 t.Fatalf("set topic title: %v", err)
172 }
173 path := writeTopicSessionWithPrompt(t, dir, "job.jsonl", topicID, topicTitle, "", "job prompt", time.Now())
174
175 jm := jobs.NewManager(event.Discard)
176 release := make(chan struct{})
177 jm.StartForSession(agent.BranchID(path), "bash", "sleep", func(ctx context.Context, _ io.Writer) (string, error) {
178 select {
179 case <-ctx.Done():
180 return "", ctx.Err()
181 case <-release:
182 return "", nil
183 }
184 })
185 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "job", Jobs: jm})
186 defer ctrl.Close()
187 app := NewApp()
188 app.tabs["job"] = &WorkspaceTab{
189 ID: "job",
190 Scope: "global",
191 WorkspaceRoot: globalTabWorkspaceRoot(),
192 TopicID: topicID,
193 TopicTitle: topicTitle,
194 SessionPath: path,
195 Ctrl: ctrl,
196 Ready: true,
197 disabledMCP: map[string]ServerView{},
198 }
199 app.tabOrder = []string{"job"}
200 app.activeTabID = "job"
201
202 nodes := app.ListProjectTree()
203 if len(nodes) != 1 || len(nodes[0].Children) != 1 {
204 t.Fatalf("project tree = %#v, want one global topic", nodes)
205 }
206 topic := nodes[0].Children[0]
207 if topic.Status != topicStatusBackgroundJob || !topic.Running {
208 t.Fatalf("background job topic status = %+v, want background_job/running", topic)
209 }
210 tabs := app.ListTabs()
211 if len(tabs) != 1 {
212 t.Fatalf("tabs = %d, want 1", len(tabs))
213 }
214 if !tabs[0].Running || tabs[0].PendingPrompt || tabs[0].Cancellable || tabs[0].BackgroundJobs != 1 {
215 t.Fatalf("tab runtime = running:%v pending:%v cancellable:%v background:%d, want background-only running tab", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].BackgroundJobs)
216 }
217 raw, err := json.Marshal(tabs[0])
218 if err != nil {
219 t.Fatalf("marshal tab meta: %v", err)
220 }
221 if !strings.Contains(string(raw), `"cancellable":false`) {
222 t.Fatalf("tab metadata should serialize explicit cancellable=false: %s", raw)
223 }
224
225 close(release)
226 waitNoJobs(t, ctrl)
227 nodes = app.ListProjectTree()
228 if len(nodes) != 1 || len(nodes[0].Children) != 1 {
229 t.Fatalf("project tree after job finish = %#v, want one global topic", nodes)
230 }
231 topic = nodes[0].Children[0]
232 if topic.Status != "" || topic.Running {
233 t.Fatalf("background job topic status after finish = %+v, want idle", topic)
234 }
235 }
236
237 func TestBackgroundJobNoticeForcesProjectTreeRefresh(t *testing.T) {
238 isolateDesktopUserDirs(t)
239
240 app := NewApp()
241 app.ctx = context.Background()
242 legacyInvalidations := 0
243 app.projectTreeChangedHook = func() { legacyInvalidations++ }
244 events := make(chan ProjectTreeRuntimeSnapshot, 1)
245 app.runtimeEvents.emit = func(_ context.Context, name string, payload ...any) {
246 if name == "project-tree:runtime-changed" && len(payload) == 1 {
247 if snapshot, ok := payload[0].(ProjectTreeRuntimeSnapshot); ok {
248 events <- snapshot
249 }
250 }
251 }
252 app.tabs["job"] = &WorkspaceTab{
253 ID: "job",
254 Scope: "global",
255 TopicID: "topic_background_notice",
256 Ready: true,
257 disabledMCP: map[string]ServerView{},
258 }
259 sink := &tabEventSink{tabID: "job", app: app}
260
261 sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "background bash finished: bash-1"})
262 if legacyInvalidations != 0 {
263 t.Fatalf("catalog/legacy invalidations = %d, want 0 for runtime-only status", legacyInvalidations)
264 }
265 select {
266 case snapshot := <-events:
267 if snapshot.Revision == 0 || snapshot.Topics == nil {
268 t.Fatalf("runtime snapshot = %+v, want versioned non-nil projection", snapshot)
269 }
270 case <-time.After(time.Second):
271 t.Fatal("background job finish notice emitted no project-tree runtime snapshot")
272 }
273 }
274
275 func waitNoJobs(t *testing.T, ctrl control.SessionAPI) {
276 t.Helper()
277 deadline := time.Now().Add(time.Second)
278 for len(ctrl.Jobs()) > 0 {
279 if time.Now().After(deadline) {
280 t.Fatalf("controller still has jobs: %+v", ctrl.Jobs())
281 }
282 time.Sleep(10 * time.Millisecond)
283 }
284 }
285
286 func TestTopicActivityStatusPresentsReadinessSeparatelyFromPause(t *testing.T) {
287 readiness := event.Event{
288 Kind: event.TurnDone,
289 Err: &agent.FinalReadinessError{Attempts: 3, Reason: "missing verification"},
290 Outcome: event.TurnOutcomeFinalReadiness,
291 }
292 if status, ok := topicActivityStatusFromEvent(readiness); !ok || status != topicStatusAwaitingDelivery {
293 t.Fatalf("readiness turn end = (%q, %v), want (%q, true)", status, ok, topicStatusAwaitingDelivery)
294 }
295 recoveryPause := event.Event{
296 Kind: event.TurnDone,
297 Err: &agent.RecoveryPauseError{Message: "automatic recovery paused"},
298 Outcome: event.TurnOutcomeRecoveryPaused,
299 }
300 if status, ok := topicActivityStatusFromEvent(recoveryPause); !ok || status != topicStatusPaused {
301 t.Fatalf("recovery pause turn end = (%q, %v), want (%q, true)", status, ok, topicStatusPaused)
302 }
303 if status, ok := topicActivityStatusFromEvent(event.Event{Kind: event.TurnDone, Err: io.EOF}); !ok || status != topicStatusError {
304 t.Fatalf("ordinary turn error = (%q, %v), want (%q, true)", status, ok, topicStatusError)
305 }
306 if status, ok := topicActivityStatusFromEvent(event.Event{Kind: event.TurnDone}); !ok || status != "" {
307 t.Fatalf("clean turn end = (%q, %v), want cleared status", status, ok)
308 }
309 }
310
311 func TestCatalogRuntimeStatusPreservesDeliveryCheckWhenIdle(t *testing.T) {
312 got := catalogRuntimeStatus(topicStatusAwaitingDelivery, control.RuntimeStatus{})
313 if got != topicStatusAwaitingDelivery {
314 t.Fatalf("idle delivery check = %q, want %q", got, topicStatusAwaitingDelivery)
315 }
316 got = catalogRuntimeStatus(topicStatusAwaitingDelivery, control.RuntimeStatus{Running: true})
317 if got != topicStatusThinking {
318 t.Fatalf("running delivery check = %q, want %q", got, topicStatusThinking)
319 }
320 got = catalogRuntimeStatus(topicStatusPaused, control.RuntimeStatus{})
321 if got != topicStatusPaused {
322 t.Fatalf("idle recovery pause = %q, want %q", got, topicStatusPaused)
323 }
324 }
325
325 lines GO