返回 DeepSeek-Reasonix
model_test.go
根目录 / internal / taskmonitor / model_test.go
1 package taskmonitor
2
3 import (
4 "context"
5 "encoding/json"
6 "strings"
7 "testing"
8 "time"
9 )
10
11 func TestTaskStateIsKnown(t *testing.T) {
12 for _, s := range []TaskState{
13 TaskStateQueued, TaskStateRunning, TaskStateWaiting,
14 TaskStateSucceeded, TaskStateFailed, TaskStateCancelled, TaskStateStale,
15 } {
16 if !s.IsKnown() {
17 t.Errorf("expected IsKnown=true for %q", s)
18 }
19 }
20 if TaskState("bogus").IsKnown() {
21 t.Error("expected IsKnown=false for unknown state")
22 }
23 }
24
25 func TestTaskStateTerminal(t *testing.T) {
26 for _, s := range []TaskState{
27 TaskStateSucceeded, TaskStateFailed, TaskStateCancelled, TaskStateStale,
28 } {
29 if !s.Terminal() {
30 t.Errorf("expected Terminal=true for %q", s)
31 }
32 }
33 for _, s := range []TaskState{TaskStateQueued, TaskStateRunning, TaskStateWaiting} {
34 if s.Terminal() {
35 t.Errorf("expected Terminal=false for %q", s)
36 }
37 }
38 }
39
40 func TestTaskStateValidTransition(t *testing.T) {
41 tests := []struct {
42 from, to TaskState
43 valid bool
44 }{
45 // queued
46 {TaskStateQueued, TaskStateRunning, true},
47 {TaskStateQueued, TaskStateCancelled, true},
48 {TaskStateQueued, TaskStateStale, true},
49 {TaskStateQueued, TaskStateSucceeded, false},
50 {TaskStateQueued, TaskStateFailed, false},
51 {TaskStateQueued, TaskStateQueued, false},
52 // running
53 {TaskStateRunning, TaskStateWaiting, true},
54 {TaskStateRunning, TaskStateSucceeded, true},
55 {TaskStateRunning, TaskStateFailed, true},
56 {TaskStateRunning, TaskStateCancelled, true},
57 {TaskStateRunning, TaskStateStale, true},
58 {TaskStateRunning, TaskStateQueued, false},
59 // waiting
60 {TaskStateWaiting, TaskStateRunning, true},
61 {TaskStateWaiting, TaskStateSucceeded, true},
62 {TaskStateWaiting, TaskStateFailed, true},
63 {TaskStateWaiting, TaskStateCancelled, true},
64 {TaskStateWaiting, TaskStateStale, true},
65 {TaskStateWaiting, TaskStateQueued, false},
66 // terminal → anything (including unknown) is invalid
67 {TaskStateSucceeded, TaskStateRunning, false},
68 {TaskStateFailed, TaskStateRunning, false},
69 {TaskStateCancelled, TaskStateRunning, false},
70 {TaskStateStale, TaskStateRunning, false},
71 {TaskStateSucceeded, "future-state", false},
72 {TaskStateFailed, "future-state", false},
73 {TaskStateCancelled, "future-state", false},
74 {TaskStateStale, "future-state", false},
75 }
76 for _, tc := range tests {
77 got := tc.from.ValidTransition(tc.to)
78 if got != tc.valid {
79 t.Errorf("%s → %s: expected valid=%v, got %v", tc.from, tc.to, tc.valid, got)
80 }
81 }
82 }
83
84 func TestTaskStateUnknownTransition(t *testing.T) {
85 // unknown → known: allowed (forward-compat)
86 if !TaskState("future-state").ValidTransition(TaskStateRunning) {
87 t.Error("unknown state should allow transitions to known states")
88 }
89 // known non-terminal → unknown: allowed
90 if !TaskStateQueued.ValidTransition("future-state") {
91 t.Error("known non-terminal state should allow transitions to unknown states")
92 }
93 }
94
95 func TestTaskStateUnmarshalJSON_Unknown(t *testing.T) {
96 var s TaskState
97 if err := json.Unmarshal([]byte(`"brand-new-state"`), &s); err != nil {
98 t.Fatalf("unmarshal: %v", err)
99 }
100 if s != "brand-new-state" {
101 t.Errorf("expected 'brand-new-state', got %q", s)
102 }
103 if s.IsKnown() {
104 t.Error("unknown state should not report IsKnown")
105 }
106 }
107
108 func TestTaskStateUnmarshalJSON_Known(t *testing.T) {
109 var s TaskState
110 if err := json.Unmarshal([]byte(`"running"`), &s); err != nil {
111 t.Fatalf("unmarshal: %v", err)
112 }
113 if s != TaskStateRunning {
114 t.Errorf("expected running, got %q", s)
115 }
116 }
117
118 func TestRuntimeStateEffective_LegacyAndKnownValues(t *testing.T) {
119 if got := (RuntimeState("")).Effective(); got != RuntimeStateUnknown {
120 t.Fatalf("legacy empty runtime state = %q, want unknown", got)
121 }
122 for _, state := range []RuntimeState{RuntimeStateUnknown, RuntimeStateAlive, RuntimeStateExited} {
123 if !state.IsKnown() || state.Effective() != state {
124 t.Fatalf("runtime state %q was not preserved as known", state)
125 }
126 }
127 if RuntimeState("future-runtime").IsKnown() {
128 t.Fatal("future runtime state should remain forward-compatible but unknown")
129 }
130 }
131
132 // TaskSnapshot
133
134 func TestTaskSnapshotValidate_Valid(t *testing.T) {
135 ts := TaskSnapshot{
136 SchemaVersion: 1, TaskID: "task-1", SessionID: "sess-1",
137 State: TaskStateRunning, CreatedAt: time.Now().Add(-time.Hour), UpdatedAt: time.Now(),
138 }
139 if err := ts.Validate(); err != nil {
140 t.Errorf("expected valid, got: %v", err)
141 }
142 }
143
144 func TestTaskSnapshotValidate_MissingFields(t *testing.T) {
145 tests := []struct {
146 name string
147 snap TaskSnapshot
148 want string
149 }{
150 {"no TaskID", TaskSnapshot{SessionID: "s", State: TaskStateQueued, SchemaVersion: 1}, "TaskID"},
151 {"no State", TaskSnapshot{TaskID: "t", SessionID: "s", SchemaVersion: 1}, "State"},
152 {"bad SchemaVersion", TaskSnapshot{TaskID: "t", SessionID: "s", State: TaskStateQueued, SchemaVersion: 0, CreatedAt: time.Now(), UpdatedAt: time.Now()}, "SchemaVersion"},
153 }
154 for _, tc := range tests {
155 err := tc.snap.Validate()
156 if err == nil {
157 t.Errorf("%s: expected error, got nil", tc.name)
158 continue
159 }
160 if !strings.Contains(err.Error(), tc.want) {
161 t.Errorf("%s: expected %q in error, got %q", tc.name, tc.want, err.Error())
162 }
163 }
164 }
165
166 func TestTaskSnapshotValidate_UpdatedBeforeCreated(t *testing.T) {
167 ts := TaskSnapshot{
168 SchemaVersion: 1, TaskID: "t", SessionID: "s",
169 State: TaskStateQueued, CreatedAt: time.Now(), UpdatedAt: time.Now().Add(-time.Hour),
170 }
171 err := ts.Validate()
172 if err == nil || !strings.Contains(err.Error(), "before CreatedAt") {
173 t.Fatalf("expected 'before CreatedAt' error, got %v", err)
174 }
175 }
176
177 func TestTaskSnapshotValidate_FieldLengthLimits(t *testing.T) {
178 long := strings.Repeat("x", maxFieldLen+1)
179 longSummary := strings.Repeat("y", maxErrorSummaryLen+1)
180 tests := []struct {
181 name string
182 snap TaskSnapshot
183 want string
184 }{
185 {"TaskID too long", TaskSnapshot{
186 SchemaVersion: 1, TaskID: long, SessionID: "s",
187 State: TaskStateQueued, CreatedAt: time.Now(), UpdatedAt: time.Now(),
188 }, "TaskID exceeds"},
189 {"JobID too long", TaskSnapshot{
190 SchemaVersion: 1, TaskID: "t", JobID: long, SessionID: "s",
191 State: TaskStateQueued, CreatedAt: time.Now(), UpdatedAt: time.Now(),
192 }, "JobID exceeds"},
193 {"SessionID too long", TaskSnapshot{
194 SchemaVersion: 1, TaskID: "t", SessionID: long,
195 State: TaskStateQueued, CreatedAt: time.Now(), UpdatedAt: time.Now(),
196 }, "SessionID exceeds"},
197 {"ErrorCode too long", TaskSnapshot{
198 SchemaVersion: 1, TaskID: "t", SessionID: "s",
199 State: TaskStateFailed, CreatedAt: time.Now(), UpdatedAt: time.Now(),
200 ErrorCode: long,
201 }, "ErrorCode exceeds"},
202 {"RuntimeState too long", TaskSnapshot{
203 SchemaVersion: 1, TaskID: "t", SessionID: "s",
204 State: TaskStateRunning, RuntimeState: RuntimeState(long),
205 CreatedAt: time.Now(), UpdatedAt: time.Now(),
206 }, "RuntimeState exceeds"},
207 {"ErrorSummary too long", TaskSnapshot{
208 SchemaVersion: 1, TaskID: "t", SessionID: "s",
209 State: TaskStateFailed, CreatedAt: time.Now(), UpdatedAt: time.Now(),
210 ErrorSummary: longSummary,
211 }, "ErrorSummary exceeds"},
212 }
213 for _, tc := range tests {
214 err := tc.snap.Validate()
215 if err == nil || !strings.Contains(err.Error(), tc.want) {
216 t.Errorf("%s: expected %q in error, got %v", tc.name, tc.want, err)
217 }
218 }
219 }
220
221 func TestTaskSnapshotJSON_RoundTrip(t *testing.T) {
222 now := time.Now().Truncate(time.Second)
223 ts := TaskSnapshot{
224 SchemaVersion: 1, TaskID: "s1--t1", JobID: "t1", SessionID: "s1",
225 State: TaskStateFailed, RuntimeState: RuntimeStateExited,
226 CreatedAt: now.Add(-time.Hour), UpdatedAt: now,
227 ErrorCode: "TIMEOUT", ErrorSummary: "task exceeded deadline",
228 }
229 data, err := json.Marshal(ts)
230 if err != nil {
231 t.Fatalf("marshal: %v", err)
232 }
233 var got TaskSnapshot
234 if err := json.Unmarshal(data, &got); err != nil {
235 t.Fatalf("unmarshal: %v", err)
236 }
237 if got.TaskID != ts.TaskID || got.JobID != ts.JobID || got.State != ts.State || got.RuntimeState != ts.RuntimeState || got.ErrorCode != ts.ErrorCode {
238 t.Errorf("round-trip mismatch")
239 }
240 }
241
242 func TestReconcileRuntimeExpiredLease(t *testing.T) {
243 now := time.Now().UTC()
244 snap := TaskSnapshot{
245 SchemaVersion: 1, TaskID: "task-1", SessionID: "s1", State: TaskStateRunning,
246 RuntimeState: RuntimeStateAlive, RuntimeLeaseUntil: now.Add(-time.Second),
247 CreatedAt: now.Add(-time.Minute), UpdatedAt: now.Add(-time.Minute),
248 }
249 reconcileRuntime(&snap, now)
250 if snap.State != TaskStateStale || snap.RuntimeState != RuntimeStateExited {
251 t.Fatalf("reconciled snapshot = %+v", snap)
252 }
253 }
254
255 func TestTaskSnapshotJSON_LegacyMissingRuntimeState(t *testing.T) {
256 raw := `{"schema_version":1,"task_id":"legacy","session_id":"s","state":"running","version":1,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:01Z"}`
257 var snap TaskSnapshot
258 if err := json.Unmarshal([]byte(raw), &snap); err != nil {
259 t.Fatalf("unmarshal legacy snapshot: %v", err)
260 }
261 if got := snap.RuntimeState.Effective(); got != RuntimeStateUnknown {
262 t.Fatalf("legacy runtime state = %q, want unknown", got)
263 }
264 if snap.JobID != "" || runtimeJobID(&snap) != snap.TaskID {
265 t.Fatalf("legacy job identity = %q/%q", snap.JobID, runtimeJobID(&snap))
266 }
267 if err := snap.Validate(); err != nil {
268 t.Fatalf("legacy snapshot should remain valid: %v", err)
269 }
270 }
271
272 // TaskEvent
273
274 func TestTaskEventValidate_Valid(t *testing.T) {
275 ev := TaskEvent{
276 Sequence: 1, Timestamp: time.Now(), EventType: "state_change",
277 TaskID: "t1", SessionID: "s1", State: TaskStateRunning,
278 }
279 if err := ev.Validate(); err != nil {
280 t.Errorf("expected valid, got: %v", err)
281 }
282 }
283
284 func TestTaskEventValidate_MissingFields(t *testing.T) {
285 tests := []struct {
286 name string
287 event TaskEvent
288 want string
289 }{
290 {"zero Sequence", TaskEvent{Timestamp: time.Now(), EventType: "e", TaskID: "t", SessionID: "s", State: TaskStateQueued}, "Sequence"},
291 {"no TaskID", TaskEvent{Sequence: 1, Timestamp: time.Now(), EventType: "e", SessionID: "s", State: TaskStateQueued}, "TaskID"},
292 {"no EventType", TaskEvent{Sequence: 1, Timestamp: time.Now(), TaskID: "t", SessionID: "s", State: TaskStateQueued}, "EventType"},
293 {"no State", TaskEvent{Sequence: 1, Timestamp: time.Now(), EventType: "e", TaskID: "t", SessionID: "s"}, "State"},
294 {"no Timestamp", TaskEvent{Sequence: 1, EventType: "e", TaskID: "t", SessionID: "s", State: TaskStateQueued}, "Timestamp"},
295 }
296 for _, tc := range tests {
297 err := tc.event.Validate()
298 if err == nil || !strings.Contains(err.Error(), tc.want) {
299 t.Errorf("%s: expected %q in error, got %v", tc.name, tc.want, err)
300 }
301 }
302 }
303
304 func TestTaskEventValidate_FieldLengthLimits(t *testing.T) {
305 long := strings.Repeat("x", maxFieldLen+1)
306 longSummary := strings.Repeat("y", maxErrorSummaryLen+1)
307 base := TaskEvent{
308 Sequence: 1, Timestamp: time.Now(), EventType: "e",
309 TaskID: "t", SessionID: "s", State: TaskStateQueued,
310 }
311 tests := []struct {
312 name string
313 event TaskEvent
314 want string
315 }{
316 {"TaskID too long", withField(base, "TaskID", long), "TaskID exceeds"},
317 {"SessionID too long", withField(base, "SessionID", long), "SessionID exceeds"},
318 {"EventType too long", withField(base, "EventType", long), "EventType exceeds"},
319 {"ErrorCode too long", withField(base, "ErrorCode", long), "ErrorCode exceeds"},
320 {"RuntimeState too long", withField(base, "RuntimeState", long), "RuntimeState exceeds"},
321 {"ErrorSummary too long", withField(base, "ErrorSummary", longSummary), "ErrorSummary exceeds"},
322 }
323 for _, tc := range tests {
324 err := tc.event.Validate()
325 if err == nil || !strings.Contains(err.Error(), tc.want) {
326 t.Errorf("%s: expected %q in error, got %v", tc.name, tc.want, err)
327 }
328 }
329 }
330
331 func withField(ev TaskEvent, field, val string) TaskEvent {
332 switch field {
333 case "TaskID":
334 ev.TaskID = val
335 case "SessionID":
336 ev.SessionID = val
337 case "EventType":
338 ev.EventType = val
339 case "ErrorCode":
340 ev.ErrorCode = val
341 case "RuntimeState":
342 ev.RuntimeState = RuntimeState(val)
343 case "ErrorSummary":
344 ev.ErrorSummary = val
345 }
346 return ev
347 }
348
349 func TestTaskEventJSON_NoSensitiveFields(t *testing.T) {
350 raw := `{
351 "sequence": 1, "timestamp": "2025-01-01T00:00:00Z",
352 "event_type": "tool_dispatch", "task_id": "t1", "session_id": "s1",
353 "state": "running",
354 "prompt": "SECRET", "tool_args": "rm -rf /",
355 "tool_result": "sensitive", "reasoning": "private"
356 }`
357 var ev TaskEvent
358 if err := json.Unmarshal([]byte(raw), &ev); err != nil {
359 t.Fatalf("unmarshal: %v", err)
360 }
361 data, _ := json.Marshal(ev)
362 s := string(data)
363 for _, forbidden := range []string{"SECRET", "rm -rf", "sensitive", "private"} {
364 if strings.Contains(s, forbidden) {
365 t.Errorf("output contains forbidden content %q: %s", forbidden, s)
366 }
367 }
368 }
369
370 // InMemoryStore
371
372 func seedTime(i int) time.Time {
373 return time.Date(2025, 1, 1, 0, 0, i, 0, time.UTC)
374 }
375
376 func TestInMemoryStore_ListTasks_Empty(t *testing.T) {
377 store := NewInMemoryStore()
378 tasks, err := store.ListTasks(context.Background(), "/proj")
379 if err != nil || len(tasks) != 0 {
380 t.Fatalf("expected empty, got %d tasks, err=%v", len(tasks), err)
381 }
382 }
383
384 func TestInMemoryStore_ListTasks_ProjectIsolation(t *testing.T) {
385 store := NewInMemoryStore()
386 mustUpsert(t, store, "/proj-a", TaskSnapshot{
387 SchemaVersion: 1, TaskID: "a1", SessionID: "s1",
388 State: TaskStateRunning, CreatedAt: seedTime(1), UpdatedAt: seedTime(10),
389 })
390 mustUpsert(t, store, "/proj-b", TaskSnapshot{
391 SchemaVersion: 1, TaskID: "b1", SessionID: "s3",
392 State: TaskStateFailed, CreatedAt: seedTime(3), UpdatedAt: seedTime(12),
393 })
394 aTasks, _ := store.ListTasks(context.Background(), "/proj-a")
395 if len(aTasks) != 1 || aTasks[0].TaskID != "a1" {
396 t.Fatalf("expected [a1] in /proj-a")
397 }
398 unknown, _ := store.ListTasks(context.Background(), "/no-such")
399 if len(unknown) != 0 {
400 t.Errorf("expected empty, got %d", len(unknown))
401 }
402 }
403
404 func TestInMemoryStore_ListTasks_AllProjects(t *testing.T) {
405 store := NewInMemoryStore()
406 mustUpsert(t, store, "/proj-a", TaskSnapshot{
407 SchemaVersion: 1, TaskID: "a1", SessionID: "s",
408 State: TaskStateRunning, CreatedAt: seedTime(1), UpdatedAt: seedTime(10),
409 })
410 mustUpsert(t, store, "/proj-b", TaskSnapshot{
411 SchemaVersion: 1, TaskID: "b1", SessionID: "s",
412 State: TaskStateFailed, CreatedAt: seedTime(2), UpdatedAt: seedTime(11),
413 })
414 tasks, _ := store.ListTasks(context.Background(), "")
415 if len(tasks) != 2 {
416 t.Fatalf("expected 2 tasks, got %d", len(tasks))
417 }
418 }
419
420 func TestInMemoryStore_ListTasks_SortOrder(t *testing.T) {
421 store := NewInMemoryStore()
422 mustUpsert(t, store, "/p", TaskSnapshot{
423 SchemaVersion: 1, TaskID: "old", SessionID: "s", State: TaskStateQueued,
424 CreatedAt: seedTime(1), UpdatedAt: seedTime(5),
425 })
426 mustUpsert(t, store, "/p", TaskSnapshot{
427 SchemaVersion: 1, TaskID: "new", SessionID: "s", State: TaskStateRunning,
428 CreatedAt: seedTime(2), UpdatedAt: seedTime(10),
429 })
430 tasks, _ := store.ListTasks(context.Background(), "/p")
431 if tasks[0].TaskID != "new" || tasks[1].TaskID != "old" {
432 t.Errorf("sort order wrong: [0]=%q [1]=%q", tasks[0].TaskID, tasks[1].TaskID)
433 }
434 }
435
436 func TestInMemoryStore_GetTask_ProjectIsolation(t *testing.T) {
437 store := NewInMemoryStore()
438 mustUpsert(t, store, "/proj-a", TaskSnapshot{
439 SchemaVersion: 1, TaskID: "t1", SessionID: "s",
440 State: TaskStateRunning, CreatedAt: seedTime(1), UpdatedAt: seedTime(2),
441 })
442 // same task in different project — should not be visible
443 snap, err := store.GetTask(context.Background(), "/proj-b", "t1")
444 if err != nil || snap != nil {
445 t.Fatalf("expected nil in /proj-b, got snap=%v err=%v", snap, err)
446 }
447 // in /proj-a it should be found
448 snap, err = store.GetTask(context.Background(), "/proj-a", "t1")
449 if err != nil || snap == nil {
450 t.Fatalf("expected snapshot in /proj-a, got err=%v", err)
451 }
452 }
453
454 func TestInMemoryStore_GetTask_Found(t *testing.T) {
455 store := NewInMemoryStore()
456 mustUpsert(t, store, "/p", TaskSnapshot{
457 SchemaVersion: 1, TaskID: "t1", SessionID: "s1",
458 State: TaskStateFailed, CreatedAt: seedTime(1), UpdatedAt: seedTime(2),
459 ErrorCode: "EXIT_42",
460 })
461 snap, err := store.GetTask(context.Background(), "/p", "t1")
462 if err != nil || snap == nil || snap.ErrorCode != "EXIT_42" {
463 t.Fatalf("GetTask: err=%v snap=%v", err, snap)
464 }
465 // mutation safety
466 snap.ErrorCode = "MUTATED"
467 snap2, _ := store.GetTask(context.Background(), "/p", "t1")
468 if snap2.ErrorCode == "MUTATED" {
469 t.Error("GetTask must return a copy")
470 }
471 }
472
473 func TestInMemoryStore_GetTask_NotFound(t *testing.T) {
474 store := NewInMemoryStore()
475 snap, err := store.GetTask(context.Background(), "", "ghost")
476 if err != nil || snap != nil {
477 t.Errorf("expected nil,nil, got %v,%v", snap, err)
478 }
479 }
480
481 func TestInMemoryStore_ListEvents_Empty(t *testing.T) {
482 store := NewInMemoryStore()
483 events, _ := store.ListEvents(context.Background(), "", "no-task", 0)
484 if len(events) != 0 {
485 t.Errorf("expected empty, got %d", len(events))
486 }
487 }
488
489 func TestInMemoryStore_ListEvents_SequenceOrder(t *testing.T) {
490 store := NewInMemoryStore()
491 for i := 1; i <= 5; i++ {
492 mustAppend(t, store, "/p", TaskEvent{
493 Sequence: i, Timestamp: seedTime(i), EventType: "e",
494 TaskID: "t", SessionID: "s", State: TaskStateRunning,
495 })
496 }
497 events, _ := store.ListEvents(context.Background(), "/p", "t", 0)
498 if len(events) != 5 {
499 t.Fatalf("expected 5 events, got %d", len(events))
500 }
501 for i, ev := range events {
502 if ev.Sequence != i+1 {
503 t.Errorf("event[%d].Sequence=%d, want %d", i, ev.Sequence, i+1)
504 }
505 }
506 }
507
508 func TestInMemoryStore_ListEvents_Cursor(t *testing.T) {
509 store := NewInMemoryStore()
510 for i := 1; i <= 5; i++ {
511 mustAppend(t, store, "/p", TaskEvent{
512 Sequence: i, Timestamp: seedTime(i), EventType: "e",
513 TaskID: "t", SessionID: "s", State: TaskStateRunning,
514 })
515 }
516 events, _ := store.ListEvents(context.Background(), "/p", "t", 3)
517 if len(events) != 2 || events[0].Sequence != 4 || events[1].Sequence != 5 {
518 t.Errorf("expected events 4,5, got %v", events)
519 }
520 }
521
522 func TestInMemoryStore_ListEvents_ProjectIsolation(t *testing.T) {
523 store := NewInMemoryStore()
524 mustAppend(t, store, "/proj-a", TaskEvent{
525 Sequence: 1, Timestamp: seedTime(1), EventType: "e",
526 TaskID: "t", SessionID: "s", State: TaskStateRunning,
527 })
528 // Query from a different project
529 events, _ := store.ListEvents(context.Background(), "/proj-b", "t", 0)
530 if len(events) != 0 {
531 t.Errorf("expected empty in /proj-b, got %d events", len(events))
532 }
533 }
534
535 // Event validation
536
537 func TestInMemoryStore_AppendEvent_RejectsDuplicateSequence(t *testing.T) {
538 store := NewInMemoryStore()
539 mustAppend(t, store, "/p", TaskEvent{
540 Sequence: 1, Timestamp: seedTime(1), EventType: "e",
541 TaskID: "t", SessionID: "s", State: TaskStateRunning,
542 })
543 err := store.AppendEvent("/p", TaskEvent{
544 Sequence: 1, Timestamp: seedTime(2), EventType: "e",
545 TaskID: "t", SessionID: "s", State: TaskStateRunning,
546 })
547 if err == nil || !strings.Contains(err.Error(), "strictly greater") {
548 t.Fatalf("expected 'strictly greater' error for duplicate seq, got %v", err)
549 }
550 }
551
552 func TestInMemoryStore_AppendEvent_RejectsRegressingSequence(t *testing.T) {
553 store := NewInMemoryStore()
554 mustAppend(t, store, "/p", TaskEvent{
555 Sequence: 5, Timestamp: seedTime(1), EventType: "e",
556 TaskID: "t", SessionID: "s", State: TaskStateRunning,
557 })
558 err := store.AppendEvent("/p", TaskEvent{
559 Sequence: 3, Timestamp: seedTime(2), EventType: "e",
560 TaskID: "t", SessionID: "s", State: TaskStateRunning,
561 })
562 if err == nil || !strings.Contains(err.Error(), "strictly greater") {
563 t.Fatalf("expected 'strictly greater' error for regressing seq, got %v", err)
564 }
565 }
566
567 func TestInMemoryStore_AppendEvent_RejectsTerminalAppend(t *testing.T) {
568 store := NewInMemoryStore()
569 mustUpsert(t, store, "/p", TaskSnapshot{
570 SchemaVersion: 1, TaskID: "t", SessionID: "s",
571 State: TaskStateSucceeded, CreatedAt: seedTime(1), UpdatedAt: seedTime(2),
572 })
573 err := store.AppendEvent("/p", TaskEvent{
574 Sequence: 1, Timestamp: seedTime(3), EventType: "e",
575 TaskID: "t", SessionID: "s", State: TaskStateRunning,
576 })
577 if err == nil || !strings.Contains(err.Error(), "terminal state") {
578 t.Fatalf("expected 'terminal state' error, got %v", err)
579 }
580 }
581
582 func TestInMemoryStore_AppendEvent_RejectsSessionIDMismatch(t *testing.T) {
583 store := NewInMemoryStore()
584 mustUpsert(t, store, "/p", TaskSnapshot{
585 SchemaVersion: 1, TaskID: "t", SessionID: "s-original",
586 State: TaskStateRunning, CreatedAt: seedTime(1), UpdatedAt: seedTime(2),
587 })
588 err := store.AppendEvent("/p", TaskEvent{
589 Sequence: 1, Timestamp: seedTime(3), EventType: "e",
590 TaskID: "t", SessionID: "s-different", State: TaskStateRunning,
591 })
592 if err == nil || !strings.Contains(err.Error(), "SessionID mismatch") {
593 t.Fatalf("expected 'SessionID mismatch' error, got %v", err)
594 }
595 }
596
597 func TestInMemoryStore_AppendEvent_UpdatesSnapshot(t *testing.T) {
598 store := NewInMemoryStore()
599 mustAppend(t, store, "/p", TaskEvent{
600 Sequence: 1, Timestamp: seedTime(1), EventType: "state_change",
601 TaskID: "t", SessionID: "s", State: TaskStateQueued,
602 })
603 mustAppend(t, store, "/p", TaskEvent{
604 Sequence: 2, Timestamp: seedTime(2), EventType: "state_change",
605 TaskID: "t", SessionID: "s", State: TaskStateRunning,
606 })
607 mustAppend(t, store, "/p", TaskEvent{
608 Sequence: 3, Timestamp: seedTime(3), EventType: "error",
609 TaskID: "t", SessionID: "s", State: TaskStateFailed,
610 ErrorCode: "CRASH", ErrorSummary: "unexpected panic",
611 })
612 snap, _ := store.GetTask(context.Background(), "/p", "t")
613 if snap.State != TaskStateFailed || snap.ErrorCode != "CRASH" {
614 t.Errorf("snapshot not updated: state=%q code=%q", snap.State, snap.ErrorCode)
615 }
616 if !snap.UpdatedAt.Equal(seedTime(3)) {
617 t.Errorf("UpdatedAt not updated: %v", snap.UpdatedAt)
618 }
619 }
620
621 func TestInMemoryStore_UpsertTask_Invalid(t *testing.T) {
622 store := NewInMemoryStore()
623 if err := store.UpsertTask("/p", TaskSnapshot{}); err == nil {
624 t.Fatal("expected error for invalid snapshot")
625 }
626 }
627
628 func TestInMemoryStore_AppendEvent_Invalid(t *testing.T) {
629 store := NewInMemoryStore()
630 if err := store.AppendEvent("/p", TaskEvent{}); err == nil {
631 t.Fatal("expected error for invalid event")
632 }
633 }
634
635 func TestInMemoryStore_ContextCancellation(t *testing.T) {
636 store := NewInMemoryStore()
637 mustUpsert(t, store, "/p", TaskSnapshot{
638 SchemaVersion: 1, TaskID: "t", SessionID: "s",
639 State: TaskStateRunning, CreatedAt: seedTime(1), UpdatedAt: seedTime(2),
640 })
641 mustAppend(t, store, "/p", TaskEvent{
642 Sequence: 1, Timestamp: seedTime(1), EventType: "e",
643 TaskID: "t", SessionID: "s", State: TaskStateRunning,
644 })
645
646 ctx, cancel := context.WithCancel(context.Background())
647 cancel()
648
649 _, err := store.ListTasks(ctx, "/p")
650 if err == nil {
651 t.Error("ListTasks should return error for cancelled context")
652 }
653 _, err = store.GetTask(ctx, "/p", "t")
654 if err == nil {
655 t.Error("GetTask should return error for cancelled context")
656 }
657 _, err = store.ListEvents(ctx, "/p", "t", 0)
658 if err == nil {
659 t.Error("ListEvents should return error for cancelled context")
660 }
661 }
662
663 func TestStore_DoesNotLeakSensitiveViaInterface(t *testing.T) {
664 store := NewInMemoryStore()
665 mustUpsert(t, store, "/p", TaskSnapshot{
666 SchemaVersion: 1, TaskID: "t", SessionID: "s",
667 State: TaskStateFailed, CreatedAt: seedTime(1), UpdatedAt: seedTime(2),
668 ErrorCode: "ERR", ErrorSummary: "safe summary",
669 })
670 snap, _ := store.GetTask(context.Background(), "/p", "t")
671 data, _ := json.Marshal(snap)
672 s := string(data)
673 for _, forbidden := range []string{"prompt", "tool_args", "tool_result", "reasoning", "approval"} {
674 if strings.Contains(s, forbidden) {
675 t.Errorf("snapshot JSON contains forbidden key %q: %s", forbidden, s)
676 }
677 }
678 }
679
680 // helpers
681
682 func mustUpsert(t *testing.T, store *InMemoryStore, proj string, snap TaskSnapshot) {
683 t.Helper()
684 if err := store.UpsertTask(proj, snap); err != nil {
685 t.Fatalf("mustUpsert: %v", err)
686 }
687 }
688
689 func mustAppend(t *testing.T, store *InMemoryStore, proj string, ev TaskEvent) {
690 t.Helper()
691 if err := store.AppendEvent(proj, ev); err != nil {
692 t.Fatalf("mustAppend: %v", err)
693 }
694 }
695
696 func TestTaskSnapshotValidate_SessionIDOptional(t *testing.T) {
697 now := time.Now()
698 snap := TaskSnapshot{SchemaVersion: 1, TaskID: "t", State: TaskStateQueued, CreatedAt: now, UpdatedAt: now}
699 if err := snap.Validate(); err != nil {
700 t.Fatalf("empty SessionID should be valid, got %v", err)
701 }
702 }
703
704 func TestTaskEventValidate_SessionIDOptional(t *testing.T) {
705 ev := TaskEvent{Sequence: 1, Timestamp: time.Now(), EventType: "e", TaskID: "t", State: TaskStateQueued}
706 if err := ev.Validate(); err != nil {
707 t.Fatalf("empty SessionID should be valid, got %v", err)
708 }
709 }
710
710 lines GO