package swarm import ( "path/filepath" "testing" "time" ) // TestEventLogAppendAndRead is the simplest possible invariant: // what we write goes back through ReadEventLog exactly, including // the flat field shape and the timestamp. func TestEventLogAppendAndRead(t *testing.T) { path := filepath.Join(t.TempDir(), "events.jsonl") log, err := OpenEventLog(path) if err != nil { t.Fatalf("open: %v", err) } want := []Event{ NewEvent("turn_start", map[string]any{"step": 0}), NewEvent("assistant_message", map[string]any{ "content": []any{map[string]any{"type": "text", "text": "hi"}}, }), NewEvent("turn_end", map[string]any{"stop": "end"}), } for _, ev := range want { if err := log.Append(ev); err != nil { t.Fatalf("append: %v", err) } } _ = log.Close() got, err := ReadEventLog(path) if err != nil { t.Fatalf("read: %v", err) } if len(got) != len(want) { t.Fatalf("got %d events, want %d", len(got), len(want)) } for i := range want { if got[i].Type != want[i].Type { t.Errorf("event %d type = %q; want %q", i, got[i].Type, want[i].Type) } } } // TestReadEventLogIgnoresGarbage documents that one malformed line // doesn't break the whole replay. The runner's responsibility is to // only write well-formed events, but a partial crash could leave // a half-written line; we don't want that to prevent the dashboard // from showing the rest. func TestReadEventLogIgnoresGarbage(t *testing.T) { path := filepath.Join(t.TempDir(), "events.jsonl") log, err := OpenEventLog(path) if err != nil { t.Fatalf("open: %v", err) } if err := log.Append(NewEvent("first", nil)); err != nil { t.Fatalf("append: %v", err) } // Sneak a garbage line in by writing directly. if _, err := log.f.WriteString("not json at all\n"); err != nil { t.Fatalf("raw write: %v", err) } if err := log.Append(NewEvent("second", nil)); err != nil { t.Fatalf("append: %v", err) } _ = log.Close() got, err := ReadEventLog(path) if err != nil { t.Fatalf("read: %v", err) } if len(got) != 2 || got[0].Type != "first" || got[1].Type != "second" { t.Fatalf("garbage tripped parser: %+v", got) } } // TestReadEventLogDeduplicatesDoubleWrites pins the read-time fix // for already-polluted events.jsonl files. The historical bug: the // supervisor parsed child stdout and called log.Append(ev), AND the // child's mirror appended the same event. Two writes per event // landed in the file in quick succession with near-identical // timestamps, and the next zot launch replayed everything twice — // the user saw every transcript line duplicated. // // The write-time fix makes the child's mirror dormant when the // supervisor is alive, but old files are still polluted. ReadEventLog // now folds back-to-back same-type identical-payload events whose // timestamps are within ~250ms of each other. func TestReadEventLogDeduplicatesDoubleWrites(t *testing.T) { path := filepath.Join(t.TempDir(), "events.jsonl") log, err := OpenEventLog(path) if err != nil { t.Fatalf("open: %v", err) } // Two writes per logical event, mimicking the supervisor + // mirror double-write pattern. Timestamps differ by a few ms // like they would in the wild. now := time.Now() writePair := func(typ string, data map[string]any) { e1 := NewEvent(typ, data) e1.Time = now if err := log.Append(e1); err != nil { t.Fatalf("append: %v", err) } e2 := NewEvent(typ, data) e2.Time = now.Add(2 * time.Millisecond) if err := log.Append(e2); err != nil { t.Fatalf("append: %v", err) } now = now.Add(500 * time.Millisecond) // next event well outside the dedup window } writePair("assistant_message", map[string]any{ "content": []any{map[string]any{"type": "text", "text": "hi"}}, }) writePair("turn_end", map[string]any{"step": float64(1)}) _ = log.Close() got, err := ReadEventLog(path) if err != nil { t.Fatalf("read: %v", err) } if len(got) != 2 { t.Fatalf("expected double-writes to collapse to 2 events; got %d", len(got)) } if got[0].Type != "assistant_message" || got[1].Type != "turn_end" { t.Errorf("unexpected types after dedup: %q, %q", got[0].Type, got[1].Type) } } // TestReadEventLogKeepsLegitimateAdjacentEvents pins the inverse: // two events of the same type with identical payloads but separated // by more than the dedup window (250ms) must both survive. The // agent legitimately can emit identical adjacent events (think: a // retry running the same tool seconds apart). func TestReadEventLogKeepsLegitimateAdjacentEvents(t *testing.T) { path := filepath.Join(t.TempDir(), "events.jsonl") log, err := OpenEventLog(path) if err != nil { t.Fatalf("open: %v", err) } now := time.Now() for i := 0; i < 3; i++ { e := NewEvent("tool_call", map[string]any{"name": "read"}) e.Time = now.Add(time.Duration(i) * time.Second) if err := log.Append(e); err != nil { t.Fatalf("append: %v", err) } } _ = log.Close() got, err := ReadEventLog(path) if err != nil { t.Fatalf("read: %v", err) } if len(got) != 3 { t.Errorf("dedup collapsed legitimately separated events; got %d, want 3", len(got)) } } // TestFollowerEmitsNewEvents follows a live file and ensures events // appended after the follower starts make it to the channel. func TestFollowerEmitsNewEvents(t *testing.T) { path := filepath.Join(t.TempDir(), "events.jsonl") // Pre-seed one event so the file exists. The follower's // contract is "emit events appended AFTER it starts", so this // first event is not expected on the channel. log, err := OpenEventLog(path) if err != nil { t.Fatalf("open: %v", err) } defer log.Close() if err := log.Append(NewEvent("preexisting", nil)); err != nil { t.Fatalf("append: %v", err) } fol := FollowEventLog(path, 10*time.Millisecond) defer fol.Close() // Give the follower one tick to read the pre-existing event so // its offset advances past the first line. After that, any new // append should show up. time.Sleep(40 * time.Millisecond) drain(fol.Events()) for i := 0; i < 3; i++ { if err := log.Append(NewEvent("live", map[string]any{"i": i})); err != nil { t.Fatalf("append %d: %v", i, err) } } got := waitFor(t, fol.Events(), 3, time.Second) if len(got) != 3 { t.Fatalf("got %d live events, want 3", len(got)) } for i, ev := range got { if ev.Type != "live" { t.Errorf("event %d type = %q; want live", i, ev.Type) } } } func drain(ch <-chan Event) { for { select { case <-ch: default: return } } } func waitFor(t *testing.T, ch <-chan Event, n int, timeout time.Duration) []Event { t.Helper() var got []Event deadline := time.After(timeout) for len(got) < n { select { case ev := <-ch: got = append(got, ev) case <-deadline: return got } } return got }