@@ -110,3 +110,160 @@ func TestRun_NilProgressDoesNotPanic(t *testing.T) {
110110 t .Fatalf ("want 4 imported, got %+v" , resNil )
111111 }
112112}
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