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