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