返回 DeepSeek-Reasonix
heartbeat_test.go
根目录 / desktop / heartbeat_test.go
1 package main
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "testing"
8 "time"
9
10 "reasonix/internal/config"
11 "reasonix/internal/control"
12 fileencoding "reasonix/internal/fileutil/encoding"
13 )
14
15 func TestHeartbeatConfigPathUsesReasonixUserStateDir(t *testing.T) {
16 isolateDesktopUserDirs(t)
17 engine := &HeartbeatEngine{}
18 want := filepath.Join(config.MemoryUserDir(), "heartbeat-tasks.json")
19
20 if got := engine.configPath(); got != want {
21 t.Fatalf("configPath = %q, want %q", got, want)
22 }
23 }
24
25 func TestHeartbeatLoadTasksDecodesGB18030Config(t *testing.T) {
26 isolateDesktopUserDirs(t)
27 engine := &HeartbeatEngine{}
28 body := `{"tasks":[{"id":"daily","title":"每日检查","prompt":"总结中文状态","interval":"1h","enabled":true}]}`
29 if err := os.MkdirAll(filepath.Dir(engine.configPath()), 0o755); err != nil {
30 t.Fatal(err)
31 }
32 if err := os.WriteFile(engine.configPath(), fileencoding.Encode(body, fileencoding.GB18030), 0o644); err != nil {
33 t.Fatal(err)
34 }
35
36 tasks := engine.loadTasks()
37 if len(tasks) != 1 || tasks[0].Title != "每日检查" || tasks[0].Prompt != "总结中文状态" {
38 t.Fatalf("loadTasks = %+v, want decoded Chinese task", tasks)
39 }
40 }
41
42 func TestHeartbeatTaskDueAtWaitsForDailySchedule(t *testing.T) {
43 loc := time.FixedZone("test", 8*60*60)
44 created := time.Date(2026, 6, 18, 8, 30, 0, 0, loc)
45 task := HeartbeatTask{
46 ID: "daily",
47 Interval: "24h|daily@09:00",
48 Enabled: true,
49 CreatedAt: created.UnixMilli(),
50 }
51
52 if heartbeatTaskDueAt(task, time.Date(2026, 6, 18, 8, 59, 0, 0, loc)) {
53 t.Fatal("daily task should wait for the configured clock time")
54 }
55 if !heartbeatTaskDueAt(task, time.Date(2026, 6, 18, 9, 0, 0, 0, loc)) {
56 t.Fatal("daily task should be due at the configured clock time")
57 }
58
59 task.LastRunAt = time.Date(2026, 6, 18, 9, 0, 0, 0, loc).UnixMilli()
60 if heartbeatTaskDueAt(task, time.Date(2026, 6, 18, 10, 0, 0, 0, loc)) {
61 t.Fatal("daily task should not run twice for the same scheduled occurrence")
62 }
63 if !heartbeatTaskDueAt(task, time.Date(2026, 6, 19, 9, 0, 0, 0, loc)) {
64 t.Fatal("daily task should be due at the next scheduled occurrence")
65 }
66 }
67
68 func TestHeartbeatTaskDueAtHonorsWeeklySelection(t *testing.T) {
69 loc := time.UTC
70 task := HeartbeatTask{
71 ID: "weekly",
72 Interval: "168h|weekly:fri@09:00",
73 Enabled: true,
74 CreatedAt: time.Date(2026, 6, 15, 8, 0, 0, 0, loc).UnixMilli(),
75 }
76
77 if heartbeatTaskDueAt(task, time.Date(2026, 6, 18, 12, 0, 0, 0, loc)) {
78 t.Fatal("weekly task should not run before the selected weekday")
79 }
80 if !heartbeatTaskDueAt(task, time.Date(2026, 6, 19, 9, 0, 0, 0, loc)) {
81 t.Fatal("weekly task should run on the selected weekday and time")
82 }
83 }
84
85 type heartbeatStatusStub struct {
86 status control.RuntimeStatus
87 }
88
89 func (s heartbeatStatusStub) RuntimeStatus() control.RuntimeStatus {
90 return s.status
91 }
92
93 type heartbeatExecuteTaskCtrlStub struct {
94 control.SessionAPI
95 status control.RuntimeStatus
96 submitted []string
97 approvalMode string
98 }
99
100 func (s *heartbeatExecuteTaskCtrlStub) RuntimeStatus() control.RuntimeStatus {
101 return s.status
102 }
103
104 func (s *heartbeatExecuteTaskCtrlStub) SubmitUserTurn(input, display string) {
105 s.submitted = append(s.submitted, input)
106 s.status.Running = true
107 }
108
109 func (s *heartbeatExecuteTaskCtrlStub) SetToolApprovalMode(mode string) {
110 s.approvalMode = mode
111 }
112
113 func (s *heartbeatExecuteTaskCtrlStub) PlanMode() bool {
114 return false
115 }
116
117 func (s *heartbeatExecuteTaskCtrlStub) AutoApproveTools() bool {
118 return false
119 }
120
121 func (s *heartbeatExecuteTaskCtrlStub) Goal() string {
122 return ""
123 }
124
125 func (s *heartbeatExecuteTaskCtrlStub) ToolApprovalMode() string {
126 return s.approvalMode
127 }
128
129 func (s *heartbeatExecuteTaskCtrlStub) SetSessionPath(string) {}
130
131 func (s *heartbeatExecuteTaskCtrlStub) SessionPath() string {
132 return ""
133 }
134
135 func (s *heartbeatExecuteTaskCtrlStub) SessionDir() string {
136 return ""
137 }
138
139 func (s *heartbeatExecuteTaskCtrlStub) Close() {}
140
141 func TestHeartbeatControllerBusyIncludesPendingPrompt(t *testing.T) {
142 if heartbeatControllerBusy(heartbeatStatusStub{status: control.RuntimeStatus{Running: false, PendingPrompt: false}}) {
143 t.Fatal("idle controller should be available for heartbeat execution")
144 }
145 if !heartbeatControllerBusy(heartbeatStatusStub{status: control.RuntimeStatus{Running: true}}) {
146 t.Fatal("running controller should be busy")
147 }
148 if !heartbeatControllerBusy(heartbeatStatusStub{status: control.RuntimeStatus{PendingPrompt: true}}) {
149 t.Fatal("pending prompt should keep controller busy")
150 }
151 }
152
153 func TestHeartbeatExecuteTaskPersistsFreshConversationTopicID(t *testing.T) {
154 isolateDesktopUserDirs(t)
155 app := NewApp()
156 app.ctx = context.Background()
157 app.readyHook = func() {}
158 app.runtimeEvents.emit = func(context.Context, string, ...interface{}) {}
159 engine := &HeartbeatEngine{
160 app: app,
161 pendingTopics: map[string]heartbeatPendingTopic{},
162 }
163 ctrl := &heartbeatExecuteTaskCtrlStub{}
164 injected := make(chan struct{})
165
166 go func() {
167 ticker := time.NewTicker(time.Millisecond)
168 defer ticker.Stop()
169 for {
170 select {
171 case <-injected:
172 return
173 case <-ticker.C:
174 var cancel context.CancelFunc
175 var tabToInject *WorkspaceTab
176 app.mu.Lock()
177 for _, tab := range app.tabs {
178 if tab == nil {
179 continue
180 }
181 tab.removed = true
182 cancel = tab.buildCancel
183 tabToInject = tab
184 break
185 }
186 app.mu.Unlock()
187 if tabToInject == nil {
188 continue
189 }
190 if cancel != nil {
191 cancel()
192 }
193 app.mu.Lock()
194 if tabToInject.Ctrl == nil {
195 tabToInject.Ctrl = ctrl
196 tabToInject.Ready = true
197 tabToInject.StartupErr = ""
198 app.advanceSessionRuntimeEpochLocked(tabToInject)
199 app.mu.Unlock()
200 close(injected)
201 return
202 }
203 app.mu.Unlock()
204 }
205 }
206 }()
207
208 got := engine.executeTask(HeartbeatTask{
209 ID: "fresh",
210 Title: "Fresh",
211 Prompt: "ping",
212 NewConversationEachRun: true,
213 ApprovalMode: "auto",
214 })
215
216 if got.TopicID == "" {
217 t.Fatal("fresh conversation task should return the newly created topic ID")
218 }
219 if got.LastRunAt == 0 {
220 t.Fatal("fresh conversation task should update LastRunAt after submit")
221 }
222 if len(ctrl.submitted) != 1 || ctrl.submitted[0] != "ping" {
223 t.Fatalf("submitted prompts = %v, want [ping]", ctrl.submitted)
224 }
225 if ctrl.approvalMode != "auto" {
226 t.Fatalf("approval mode = %q, want auto", ctrl.approvalMode)
227 }
228 pending := engine.pendingTopics["fresh"]
229 if pending.TopicID != got.TopicID || !pending.Submitted {
230 t.Fatalf("pending topic = %+v, want submitted %q", pending, got.TopicID)
231 }
232 }
233
234 func TestHeartbeatExecuteTaskSkipsPendingPrompt(t *testing.T) {
235 isolateDesktopUserDirs(t)
236 app := NewApp()
237 app.ctx = context.Background()
238 app.readyHook = func() {}
239 app.runtimeEvents.emit = func(context.Context, string, ...interface{}) {}
240 engine := &HeartbeatEngine{
241 app: app,
242 pendingTopics: map[string]heartbeatPendingTopic{},
243 }
244 ctrl := &heartbeatExecuteTaskCtrlStub{status: control.RuntimeStatus{PendingPrompt: true}}
245 injected := make(chan struct{})
246
247 go func() {
248 ticker := time.NewTicker(time.Millisecond)
249 defer ticker.Stop()
250 for {
251 select {
252 case <-injected:
253 return
254 case <-ticker.C:
255 var cancel context.CancelFunc
256 var tabToInject *WorkspaceTab
257 app.mu.Lock()
258 for _, tab := range app.tabs {
259 if tab == nil {
260 continue
261 }
262 tab.removed = true
263 cancel = tab.buildCancel
264 tabToInject = tab
265 break
266 }
267 app.mu.Unlock()
268 if tabToInject == nil {
269 continue
270 }
271 if cancel != nil {
272 cancel()
273 }
274 app.mu.Lock()
275 if tabToInject.Ctrl == nil {
276 tabToInject.Ctrl = ctrl
277 tabToInject.Ready = true
278 tabToInject.StartupErr = ""
279 app.advanceSessionRuntimeEpochLocked(tabToInject)
280 app.mu.Unlock()
281 close(injected)
282 return
283 }
284 app.mu.Unlock()
285 }
286 }
287 }()
288
289 got := engine.executeTask(HeartbeatTask{
290 ID: "fresh",
291 Title: "Fresh",
292 Prompt: "ping",
293 NewConversationEachRun: true,
294 ApprovalMode: "auto",
295 })
296
297 if got.LastRunAt != 0 {
298 t.Fatalf("pending prompt should not mark heartbeat run complete, LastRunAt=%d", got.LastRunAt)
299 }
300 if len(ctrl.submitted) != 0 {
301 t.Fatalf("submitted prompts = %v, want none while prompt is pending", ctrl.submitted)
302 }
303 if ctrl.approvalMode != "" {
304 t.Fatalf("approval mode = %q, want unchanged while prompt is pending", ctrl.approvalMode)
305 }
306 }
307
308 func TestHeartbeatTaskDueAtHonorsIntervalTimeWindow(t *testing.T) {
309 loc := time.UTC
310 lastRun := time.Date(2026, 6, 18, 16, 0, 0, 0, loc)
311 task := HeartbeatTask{
312 ID: "window",
313 Interval: "30m",
314 Enabled: true,
315 LastRunAt: lastRun.UnixMilli(),
316 TimeWindowStart: "09:00",
317 TimeWindowEnd: "17:00",
318 }
319
320 if !heartbeatTaskDueAt(task, time.Date(2026, 6, 18, 16, 30, 0, 0, loc)) {
321 t.Fatal("interval task should run in the configured time window once due")
322 }
323 if heartbeatTaskDueAt(task, time.Date(2026, 6, 18, 17, 20, 0, 0, loc)) {
324 t.Fatal("interval task should wait while outside the configured time window")
325 }
326 if !heartbeatTaskDueAt(task, time.Date(2026, 6, 19, 9, 0, 0, 0, loc)) {
327 t.Fatal("interval task should run when the next time window opens")
328 }
329
330 neverRun := HeartbeatTask{
331 ID: "never-run-window",
332 Interval: "30m",
333 Enabled: true,
334 TimeWindowStart: "09:00",
335 TimeWindowEnd: "17:00",
336 }
337 if heartbeatTaskDueAt(neverRun, time.Date(2026, 6, 18, 20, 0, 0, 0, loc)) {
338 t.Fatal("never-run interval task should wait while outside the configured time window")
339 }
340 if !heartbeatTaskDueAt(neverRun, time.Date(2026, 6, 19, 9, 0, 0, 0, loc)) {
341 t.Fatal("never-run interval task should run when the configured time window opens")
342 }
343 }
344
345 func TestHeartbeatMergeRunUpdatesPreservesConcurrentEditsAndDeletes(t *testing.T) {
346 isolateDesktopUserDirs(t)
347 engine := &HeartbeatEngine{
348 tasks: []HeartbeatTask{
349 {ID: "run", Title: "edited", Prompt: "new", Interval: "2h", Enabled: false, CreatedAt: 10},
350 {ID: "keep", Title: "keep", Interval: "1h", Enabled: true},
351 },
352 }
353
354 engine.mergeRunUpdatesLocked(map[string]HeartbeatTask{
355 "run": {
356 ID: "run",
357 Title: "old",
358 Prompt: "old",
359 Interval: "1h",
360 Enabled: true,
361 TopicID: "topic-run",
362 LastRunAt: 200,
363 CreatedAt: 100,
364 },
365 "deleted": {
366 ID: "deleted",
367 TopicID: "topic-deleted",
368 LastRunAt: 200,
369 },
370 })
371
372 if len(engine.tasks) != 2 {
373 t.Fatalf("tasks len = %d, want 2", len(engine.tasks))
374 }
375 got := engine.tasks[0]
376 if got.Title != "edited" || got.Prompt != "new" || got.Interval != "2h" || got.Enabled {
377 t.Fatalf("concurrent task edits were overwritten: %+v", got)
378 }
379 if got.TopicID != "topic-run" || got.LastRunAt != 200 || got.CreatedAt != 10 {
380 t.Fatalf("run fields were not patched correctly: %+v", got)
381 }
382 for _, task := range engine.tasks {
383 if task.ID == "deleted" {
384 t.Fatalf("deleted task was resurrected: %+v", engine.tasks)
385 }
386 }
387 }
388
389 func TestHeartbeatReplaceTasksPrunesFreshConversationPendingTopics(t *testing.T) {
390 isolateDesktopUserDirs(t)
391 engine := &HeartbeatEngine{
392 pendingTopics: map[string]heartbeatPendingTopic{
393 "fresh": {TopicID: "topic-fresh", Submitted: true},
394 "legacy": {TopicID: "topic-legacy", Submitted: true},
395 "deleted": {TopicID: "topic-deleted", Submitted: true},
396 },
397 }
398
399 err := engine.ReplaceTasks([]HeartbeatTask{
400 {ID: "fresh", NewConversationEachRun: true},
401 {ID: "legacy", NewConversationEachRun: false},
402 })
403 if err != nil {
404 t.Fatalf("ReplaceTasks: %v", err)
405 }
406
407 if len(engine.pendingTopics) != 1 {
408 t.Fatalf("pendingTopics len = %d, want 1: %+v", len(engine.pendingTopics), engine.pendingTopics)
409 }
410 if got := engine.pendingTopics["fresh"]; got.TopicID != "topic-fresh" || !got.Submitted {
411 t.Fatalf("fresh pending topic = %+v, want submitted topic-fresh", got)
412 }
413 if _, ok := engine.pendingTopics["legacy"]; ok {
414 t.Fatalf("legacy task should not keep a fresh-conversation pending topic")
415 }
416 if _, ok := engine.pendingTopics["deleted"]; ok {
417 t.Fatalf("deleted task should not keep a pending topic")
418 }
419 }
420
421 func TestHeartbeatInactiveOpenDoesNotChangeActiveTab(t *testing.T) {
422 isolateDesktopUserDirs(t)
423 projectRoot := t.TempDir()
424 app := &App{
425 tabs: map[string]*WorkspaceTab{
426 "heartbeat": {
427 ID: "heartbeat",
428 Scope: "project",
429 WorkspaceRoot: projectRoot,
430 TopicID: "topic-heartbeat",
431 TopicTitle: "Heartbeat",
432 Ready: true,
433 disabledMCP: map[string]ServerView{},
434 },
435 "active": {
436 ID: "active",
437 Scope: "project",
438 WorkspaceRoot: projectRoot,
439 TopicID: "topic-active",
440 TopicTitle: "Active",
441 Ready: true,
442 disabledMCP: map[string]ServerView{},
443 },
444 },
445 tabOrder: []string{"heartbeat", "active"},
446 activeTabID: "active",
447 }
448
449 meta, err := app.openProjectTabInactive(projectRoot, "topic-heartbeat")
450 if err != nil {
451 t.Fatalf("openProjectTabInactive: %v", err)
452 }
453 if got := app.activeTabID; got != "active" {
454 t.Fatalf("active tab = %q, want active", got)
455 }
456 if meta.ID != "heartbeat" || meta.Active {
457 t.Fatalf("inactive open meta = %+v, want heartbeat and inactive", meta)
458 }
459 }
460
461 func TestHeartbeatMergeRunUpdatesAdoptsExternalFileEdits(t *testing.T) {
462 isolateDesktopUserDirs(t)
463 engine := &HeartbeatEngine{
464 tasks: []HeartbeatTask{
465 {ID: "a", Title: "stale title", Prompt: "stale", Interval: "1h", Enabled: true},
466 },
467 }
468 // An external editor (the documented human/AI flow) rewrote the file after
469 // the engine's in-memory snapshot: task a was edited and task b was added.
470 external := []HeartbeatTask{
471 {ID: "a", Title: "edited externally", Prompt: "new prompt", Interval: "2h", Enabled: true},
472 {ID: "b", Title: "added externally", Prompt: "hello", Interval: "1h", Enabled: false},
473 }
474 if err := engine.saveTasks(external); err != nil {
475 t.Fatalf("seed external file: %v", err)
476 }
477
478 engine.mergeRunUpdatesLocked(map[string]HeartbeatTask{
479 "a": {ID: "a", TopicID: "topic-a", LastRunAt: 4242},
480 })
481
482 if len(engine.tasks) != 2 {
483 t.Fatalf("tasks len = %d, want 2 (external addition adopted): %+v", len(engine.tasks), engine.tasks)
484 }
485 got := engine.tasks[0]
486 if got.Title != "edited externally" || got.Prompt != "new prompt" || got.Interval != "2h" {
487 t.Fatalf("external edit was rolled back by the run-state save: %+v", got)
488 }
489 if got.TopicID != "topic-a" || got.LastRunAt != 4242 {
490 t.Fatalf("run state was not merged onto the disk copy: %+v", got)
491 }
492 // The full-list save must have preserved the externally added task on disk.
493 onDisk := engine.loadTasks()
494 if len(onDisk) != 2 || onDisk[1].ID != "b" || onDisk[1].Title != "added externally" {
495 t.Fatalf("externally added task was lost on save: %+v", onDisk)
496 }
497 }
498
499 func TestHeartbeatTickAdoptsExternalFileEdits(t *testing.T) {
500 isolateDesktopUserDirs(t)
501 engine := newHeartbeatEngine(nil)
502 if err := engine.saveTasks([]HeartbeatTask{{ID: "a", Title: "A", Interval: "1h", Enabled: false}}); err != nil {
503 t.Fatalf("seed file: %v", err)
504 }
505 engine.mu.Lock()
506 engine.tasks = engine.loadTasks()
507 engine.noteConfigModLocked()
508 engine.mu.Unlock()
509
510 // External edit lands after the engine last touched the file. Force the
511 // mtime forward so coarse filesystem timestamps cannot make this flaky.
512 if err := engine.saveTasks([]HeartbeatTask{
513 {ID: "a", Title: "A", Interval: "1h", Enabled: false},
514 {ID: "b", Title: "added externally", Interval: "1h", Enabled: false},
515 }); err != nil {
516 t.Fatalf("external edit: %v", err)
517 }
518 future := time.Now().Add(2 * time.Second)
519 if err := os.Chtimes(engine.configPath(), future, future); err != nil {
520 t.Fatalf("chtimes: %v", err)
521 }
522
523 engine.tick() // disabled tasks only: adoption runs, nothing executes
524
525 tasks := engine.ListTasks()
526 if len(tasks) != 2 || tasks[1].ID != "b" {
527 t.Fatalf("tick did not adopt the external edit: %+v", tasks)
528 }
529 }
530
530 lines GO