|
| 1 | +package agentimport |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "reflect" |
| 6 | + "testing" |
| 7 | + "time" |
| 8 | +) |
| 9 | + |
| 10 | +// progressSessionEvent and progressTurnEvent capture one Progress callback |
| 11 | +// invocation each, in call order, for assertion. |
| 12 | +type progressSessionEvent struct { |
| 13 | + sessionIndex, sessionTotal int |
| 14 | + agentName, sessionID string |
| 15 | + turnCount int |
| 16 | +} |
| 17 | + |
| 18 | +type progressTurnEvent struct { |
| 19 | + sessionIndex, turnIndex, turnCount int |
| 20 | +} |
| 21 | + |
| 22 | +// TestRun_ReportsProgress proves SessionStart fires exactly once per session |
| 23 | +// with correct totals, and TurnWritten fires exactly turnCount times per |
| 24 | +// session, in order, on a fixture with 2 sessions x 2 turns each. |
| 25 | +func TestRun_ReportsProgress(t *testing.T) { |
| 26 | + t.Parallel() |
| 27 | + repo, repoDir := initRepoWithCommit(t) |
| 28 | + claudeDir := t.TempDir() |
| 29 | + writeFixtureSession(t, claudeDir, "sess1.jsonl") |
| 30 | + writeFixtureSession(t, claudeDir, "sess2.jsonl") |
| 31 | + |
| 32 | + var sessionEvents []progressSessionEvent |
| 33 | + var turnEvents []progressTurnEvent |
| 34 | + progress := &Progress{ |
| 35 | + SessionStart: func(sessionIndex, sessionTotal int, agentName, sessionID string, turnCount int) { |
| 36 | + sessionEvents = append(sessionEvents, progressSessionEvent{sessionIndex, sessionTotal, agentName, sessionID, turnCount}) |
| 37 | + }, |
| 38 | + TurnWritten: func(sessionIndex, turnIndex, turnCount int) { |
| 39 | + turnEvents = append(turnEvents, progressTurnEvent{sessionIndex, turnIndex, turnCount}) |
| 40 | + }, |
| 41 | + } |
| 42 | + |
| 43 | + imp := claudeImporter{} |
| 44 | + res, err := Run(context.Background(), repo, imp, Options{ |
| 45 | + RepoRoot: repoDir, OverridePath: claudeDir, |
| 46 | + Now: time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC), |
| 47 | + Progress: progress, |
| 48 | + }) |
| 49 | + if err != nil { |
| 50 | + t.Fatal(err) |
| 51 | + } |
| 52 | + if res.TurnsImported != 4 { |
| 53 | + t.Fatalf("want 4 imported, got %+v", res) |
| 54 | + } |
| 55 | + |
| 56 | + wantAgentName := string(imp.AgentType()) |
| 57 | + wantSessions := []progressSessionEvent{ |
| 58 | + {sessionIndex: 0, sessionTotal: 2, agentName: wantAgentName, sessionID: "sess1", turnCount: 2}, |
| 59 | + {sessionIndex: 1, sessionTotal: 2, agentName: wantAgentName, sessionID: "sess2", turnCount: 2}, |
| 60 | + } |
| 61 | + if !reflect.DeepEqual(sessionEvents, wantSessions) { |
| 62 | + t.Fatalf("session events = %+v, want %+v", sessionEvents, wantSessions) |
| 63 | + } |
| 64 | + |
| 65 | + wantTurns := []progressTurnEvent{ |
| 66 | + {sessionIndex: 0, turnIndex: 0, turnCount: 2}, |
| 67 | + {sessionIndex: 0, turnIndex: 1, turnCount: 2}, |
| 68 | + {sessionIndex: 1, turnIndex: 0, turnCount: 2}, |
| 69 | + {sessionIndex: 1, turnIndex: 1, turnCount: 2}, |
| 70 | + } |
| 71 | + if !reflect.DeepEqual(turnEvents, wantTurns) { |
| 72 | + t.Fatalf("turn events = %+v, want %+v", turnEvents, wantTurns) |
| 73 | + } |
| 74 | +} |
| 75 | + |
| 76 | +// TestRun_NilProgressDoesNotPanic proves a nil Progress (the zero value of |
| 77 | +// Options.Progress) behaves identically to a reporter-enabled run: no panic, |
| 78 | +// same turn count imported. |
| 79 | +func TestRun_NilProgressDoesNotPanic(t *testing.T) { |
| 80 | + t.Parallel() |
| 81 | + claudeDir := t.TempDir() |
| 82 | + writeFixtureSession(t, claudeDir, "sess1.jsonl") |
| 83 | + writeFixtureSession(t, claudeDir, "sess2.jsonl") |
| 84 | + now := time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC) |
| 85 | + |
| 86 | + repoNil, repoNilDir := initRepoWithCommit(t) |
| 87 | + resNil, err := Run(context.Background(), repoNil, claudeImporter{}, Options{ |
| 88 | + RepoRoot: repoNilDir, OverridePath: claudeDir, Now: now, |
| 89 | + }) |
| 90 | + if err != nil { |
| 91 | + t.Fatal(err) |
| 92 | + } |
| 93 | + |
| 94 | + repoWith, repoWithDir := initRepoWithCommit(t) |
| 95 | + resWith, err := Run(context.Background(), repoWith, claudeImporter{}, Options{ |
| 96 | + RepoRoot: repoWithDir, OverridePath: claudeDir, Now: now, |
| 97 | + Progress: &Progress{ |
| 98 | + SessionStart: func(int, int, string, string, int) {}, |
| 99 | + TurnWritten: func(int, int, int) {}, |
| 100 | + }, |
| 101 | + }) |
| 102 | + if err != nil { |
| 103 | + t.Fatal(err) |
| 104 | + } |
| 105 | + |
| 106 | + if resNil.TurnsImported != resWith.TurnsImported { |
| 107 | + t.Fatalf("nil progress imported %d turns, reporter-enabled imported %d", resNil.TurnsImported, resWith.TurnsImported) |
| 108 | + } |
| 109 | + if resNil.TurnsImported != 4 { |
| 110 | + t.Fatalf("want 4 imported, got %+v", resNil) |
| 111 | + } |
| 112 | +} |
| 113 | + |
| 114 | +// progressRecorder collects Progress callback invocations for assertion. |
| 115 | +type progressRecorder struct { |
| 116 | + written []progressTurnEvent |
| 117 | + skipped []progressTurnEvent |
| 118 | +} |
| 119 | + |
| 120 | +func (r *progressRecorder) progress() *Progress { |
| 121 | + return &Progress{ |
| 122 | + TurnWritten: func(sessionIndex, turnIndex, turnCount int) { |
| 123 | + r.written = append(r.written, progressTurnEvent{sessionIndex, turnIndex, turnCount}) |
| 124 | + }, |
| 125 | + TurnSkipped: func(sessionIndex, turnIndex, turnCount int) { |
| 126 | + r.skipped = append(r.skipped, progressTurnEvent{sessionIndex, turnIndex, turnCount}) |
| 127 | + }, |
| 128 | + } |
| 129 | +} |
| 130 | + |
| 131 | +// TestRun_ReimportFiresTurnSkippedNotTurnWritten proves a re-import over an |
| 132 | +// already-imported corpus (the idempotent-skip path) reports every turn via |
| 133 | +// TurnSkipped, in order, and never via TurnWritten — the P2 Codex's pre-push |
| 134 | +// review caught: without this, a TTY progress reporter driven only by |
| 135 | +// TurnWritten freezes at "turn 0/M" on a fully-skipped session. |
| 136 | +func TestRun_ReimportFiresTurnSkippedNotTurnWritten(t *testing.T) { |
| 137 | + t.Parallel() |
| 138 | + repo, repoDir := initRepoWithCommit(t) |
| 139 | + claudeDir := t.TempDir() |
| 140 | + writeFixtureSession(t, claudeDir, "sess1.jsonl") |
| 141 | + writeFixtureSession(t, claudeDir, "sess2.jsonl") |
| 142 | + opts := Options{RepoRoot: repoDir, OverridePath: claudeDir, Now: time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC)} |
| 143 | + |
| 144 | + // First run: no progress, just to populate the store so the second run |
| 145 | + // hits the idempotent-skip path for every turn. |
| 146 | + if _, err := Run(context.Background(), repo, claudeImporter{}, opts); err != nil { |
| 147 | + t.Fatal(err) |
| 148 | + } |
| 149 | + |
| 150 | + rec := &progressRecorder{} |
| 151 | + opts.Progress = rec.progress() |
| 152 | + res, err := Run(context.Background(), repo, claudeImporter{}, opts) |
| 153 | + if err != nil { |
| 154 | + t.Fatal(err) |
| 155 | + } |
| 156 | + if res.TurnsImported != 0 || res.TurnsSkipped != 4 { |
| 157 | + t.Fatalf("want 0 imported / 4 skipped on re-import, got %+v", res) |
| 158 | + } |
| 159 | + |
| 160 | + if len(rec.written) != 0 { |
| 161 | + t.Errorf("TurnWritten fired %d times on a fully-skipped re-import, want 0: %+v", len(rec.written), rec.written) |
| 162 | + } |
| 163 | + wantSkipped := []progressTurnEvent{ |
| 164 | + {sessionIndex: 0, turnIndex: 0, turnCount: 2}, |
| 165 | + {sessionIndex: 0, turnIndex: 1, turnCount: 2}, |
| 166 | + {sessionIndex: 1, turnIndex: 0, turnCount: 2}, |
| 167 | + {sessionIndex: 1, turnIndex: 1, turnCount: 2}, |
| 168 | + } |
| 169 | + if !reflect.DeepEqual(rec.skipped, wantSkipped) { |
| 170 | + t.Fatalf("TurnSkipped events = %+v, want %+v", rec.skipped, wantSkipped) |
| 171 | + } |
| 172 | +} |
| 173 | + |
| 174 | +// TestRun_DryRunFiresTurnSkippedForEveryTurn proves DryRun — which never |
| 175 | +// writes — reports every turn via TurnSkipped and never via TurnWritten. |
| 176 | +func TestRun_DryRunFiresTurnSkippedForEveryTurn(t *testing.T) { |
| 177 | + t.Parallel() |
| 178 | + repo, repoDir := initRepoWithCommit(t) |
| 179 | + claudeDir := t.TempDir() |
| 180 | + writeFixtureSession(t, claudeDir, "sess1.jsonl") |
| 181 | + writeFixtureSession(t, claudeDir, "sess2.jsonl") |
| 182 | + |
| 183 | + rec := &progressRecorder{} |
| 184 | + res, err := Run(context.Background(), repo, claudeImporter{}, Options{ |
| 185 | + RepoRoot: repoDir, OverridePath: claudeDir, DryRun: true, |
| 186 | + Now: time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC), |
| 187 | + Progress: rec.progress(), |
| 188 | + }) |
| 189 | + if err != nil { |
| 190 | + t.Fatal(err) |
| 191 | + } |
| 192 | + if res.TurnsImported != 4 { |
| 193 | + // DryRun's Result bookkeeping is unchanged: TurnsImported still means |
| 194 | + // "would import" (see Run's DryRun branch). TurnSkipped is a separate, |
| 195 | + // additive signal that nothing was actually written. |
| 196 | + t.Fatalf("want 4 (would-import), got %+v", res) |
| 197 | + } |
| 198 | + |
| 199 | + if len(rec.written) != 0 { |
| 200 | + t.Errorf("TurnWritten fired %d times under DryRun, want 0: %+v", len(rec.written), rec.written) |
| 201 | + } |
| 202 | + if len(rec.skipped) != 4 { |
| 203 | + t.Fatalf("TurnSkipped fired %d times under DryRun, want 4: %+v", len(rec.skipped), rec.skipped) |
| 204 | + } |
| 205 | +} |
| 206 | + |
| 207 | +// TestRun_MixedSkipAndWriteSatisfiesInvariant proves the documented |
| 208 | +// invariant — for every turn, exactly one of TurnWritten/TurnSkipped fires, |
| 209 | +// so per-session written+skipped == turnCount — holds when a run mixes |
| 210 | +// already-imported sessions with a brand-new one in a single call. |
| 211 | +func TestRun_MixedSkipAndWriteSatisfiesInvariant(t *testing.T) { |
| 212 | + t.Parallel() |
| 213 | + repo, repoDir := initRepoWithCommit(t) |
| 214 | + claudeDir := t.TempDir() |
| 215 | + writeFixtureSession(t, claudeDir, "sess1.jsonl") |
| 216 | + writeFixtureSession(t, claudeDir, "sess2.jsonl") |
| 217 | + opts := Options{RepoRoot: repoDir, OverridePath: claudeDir, Now: time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC)} |
| 218 | + |
| 219 | + // Import sess1 and sess2 first, so a second run finds them already |
| 220 | + // imported while a newly-added sess3 is still fresh. |
| 221 | + if _, err := Run(context.Background(), repo, claudeImporter{}, opts); err != nil { |
| 222 | + t.Fatal(err) |
| 223 | + } |
| 224 | + writeFixtureSession(t, claudeDir, "sess3.jsonl") |
| 225 | + |
| 226 | + rec := &progressRecorder{} |
| 227 | + opts.Progress = rec.progress() |
| 228 | + res, err := Run(context.Background(), repo, claudeImporter{}, opts) |
| 229 | + if err != nil { |
| 230 | + t.Fatal(err) |
| 231 | + } |
| 232 | + if res.TurnsImported != 2 || res.TurnsSkipped != 4 { |
| 233 | + t.Fatalf("want 2 imported (sess3) / 4 skipped (sess1+sess2), got %+v", res) |
| 234 | + } |
| 235 | + |
| 236 | + counts := map[int]struct{ written, skipped int }{} |
| 237 | + for _, ev := range rec.written { |
| 238 | + c := counts[ev.sessionIndex] |
| 239 | + c.written++ |
| 240 | + counts[ev.sessionIndex] = c |
| 241 | + } |
| 242 | + for _, ev := range rec.skipped { |
| 243 | + c := counts[ev.sessionIndex] |
| 244 | + c.skipped++ |
| 245 | + counts[ev.sessionIndex] = c |
| 246 | + } |
| 247 | + |
| 248 | + // Discovery is sorted by path, so sess1=0, sess2=1, sess3=2 (each has 2 |
| 249 | + // turns per writeFixtureSession). |
| 250 | + wantBySession := map[int]struct{ written, skipped int }{ |
| 251 | + 0: {written: 0, skipped: 2}, // sess1: already imported |
| 252 | + 1: {written: 0, skipped: 2}, // sess2: already imported |
| 253 | + 2: {written: 2, skipped: 0}, // sess3: brand new |
| 254 | + } |
| 255 | + if len(counts) != len(wantBySession) { |
| 256 | + t.Fatalf("saw events for %d sessions, want %d: %+v", len(counts), len(wantBySession), counts) |
| 257 | + } |
| 258 | + for sessionIndex, want := range wantBySession { |
| 259 | + got := counts[sessionIndex] |
| 260 | + if got != want { |
| 261 | + t.Errorf("session %d: written=%d skipped=%d, want written=%d skipped=%d", |
| 262 | + sessionIndex, got.written, got.skipped, want.written, want.skipped) |
| 263 | + } |
| 264 | + if got.written+got.skipped != 2 { |
| 265 | + t.Errorf("session %d: written+skipped = %d, want turnCount 2 (invariant violated)", |
| 266 | + sessionIndex, got.written+got.skipped) |
| 267 | + } |
| 268 | + } |
| 269 | +} |
0 commit comments