thirdshift 0.12.1

Turn a GitHub issue into a ready-for-review pull request, unattended
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
//! Headless agent sessions on the chosen Harness, and their logs.

use std::cell::{OnceCell, RefCell};
#[cfg(test)]
use std::fs::{self, File};
#[cfg(test)]
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::Command;

#[cfg(test)]
use anyhow::bail;
use anyhow::{Context, Result, anyhow};

#[cfg(test)]
pub(crate) use crate::harness::claude::claude_args;
#[cfg(test)]
pub(crate) use crate::harness::codex::{codex_args, codex_prompt};
use crate::harness::interpretation::Ended;
use crate::harness::{Adapter, Choice};
use crate::issue::{IssueUrl, Repo};
use crate::logs;
use crate::progress;
use crate::prompt;
use crate::skills;

pub mod recording;
mod review_reports;
use review_reports::ReviewReports;

#[cfg(test)]
mod execution_tests;

/// The session's launch policy, retained by its Resume independently of
/// the label used for logs and progress.
#[derive(Clone, Copy)]
pub enum Purpose {
    Ordinary,
    CodeReview,
    Security,
    /// Public fix publishing uses Security's protocol without inherited
    /// GitHub credentials. Its Resume retains the same launch policy.
    SecurityPublishing,
    /// A guidance review retains security checks without delegating auditors.
    SecurityReview,
}

/// Where Session logs go: beside the owning Command log in
/// `commands/<command>/` under the repository's logs, under the User config's `logs.dir` or
/// `~/.thirdshift/logs`, each named for what is run and stamped with the
/// command's start stamp, which its Command log shares. Only once the logs
/// are [`logs::configured`].
pub struct Logs {
    /// What every log's name starts with: the issue's number for a Run,
    /// `architect` for an Architect run.
    name: String,
    dir: PathBuf,
    recording: OnceCell<recording::Recording>,
}

impl Logs {
    /// The logs of the Run on `issue`.
    pub fn of_run(issue: &IssueUrl) -> Self {
        Logs {
            name: issue.number.to_string(),
            dir: logs::session_dir(&issue.repo()),
            recording: OnceCell::new(),
        }
    }

    /// The logs of the Architect run on `repo`.
    pub fn of_architect_run(repo: &Repo) -> Self {
        Logs {
            name: "architect".to_string(),
            dir: logs::session_dir(repo),
            recording: OnceCell::new(),
        }
    }

    /// The logs of the Security run on `repo`.
    pub fn of_security_run(repo: &Repo) -> Self {
        Logs {
            name: "secure".to_string(),
            dir: logs::session_dir(repo),
            recording: OnceCell::new(),
        }
    }

    /// Where a session's stream is logged:
    /// `<root>/commands/<command>/<name>-<stamp>~<token>-<kind>.jsonl`.
    pub fn path(&self, kind: &str) -> Result<PathBuf> {
        self.recording().session_path(&self.dir, &self.name, kind)
    }

    /// Runs construct Logs before work starts. Capture once the first Session
    /// needs it, after the Command has allocated its immutable owner.
    fn recording(&self) -> &recording::Recording {
        self.recording.get_or_init(recording::Recording::capture)
    }
}

/// Where a Run's or an Architect run's sessions run: in `worktree`, with the
/// Factory skills linked into it, on the Command's `harness`, each logged in
/// its `logs`. It lasts for the steps given to [`Sessions::within`].
pub struct Sessions<'a> {
    logs: &'a Logs,
    worktree: &'a Path,
    /// How each session is run, and where progress lines go. Held, not
    /// passed to each call as the other seams' are, since the steps reach
    /// it through the `&Sessions` they are given.
    outside: RefCell<Box<dyn Outside>>,
    /// The log of the session started last, a Resume included.
    last_log: RefCell<Option<PathBuf>>,
    /// How each session ended whose last ending, its Resume's if it got one,
    /// left background work running, as in "implement session ended with a
    /// background task still running (cargo test), which was killed", oldest
    /// first.
    endings_with_killed_work: RefCell<Vec<String>>,
}

impl<'a> Sessions<'a> {
    /// Link the Factory skills into `worktree`, then take `steps`, which run
    /// their sessions on `harness` through the `Sessions` they are given.
    /// Returns what `steps` came to, with the most recent session's log, if a
    /// session created it. If a session's last ending, its Resume's if it got
    /// one, left background work to be killed and `steps` then fail, the
    /// failure names that work ahead of its own cause: the session may have
    /// stopped short of its job.
    pub fn within<T>(
        logs: &'a Logs,
        worktree: &'a Path,
        harness: &'a Choice,
        steps: impl FnOnce(&Self) -> Result<T>,
    ) -> (Result<T>, Option<PathBuf>) {
        let adapter = harness.harness.adapter();
        if let Err(error) = skills::link_into(worktree, adapter)
            .and_then(|()| adapter.link_instruction_fallback(worktree))
        {
            return (Err(error), None);
        }
        let on_machine = OnMachine {
            worktree: worktree.to_path_buf(),
            harness: harness.clone(),
            adapter,
            recording: logs.recording().clone(),
        };
        let (taken, log) = Sessions::taking(logs, worktree, Box::new(on_machine), steps);
        (taken, log.filter(|log| log.exists()))
    }

    /// [`Sessions::within`], its sessions run through `outside`, once the
    /// Factory skills are linked. The log it returns is the most recent
    /// session's, whether or not the session created it.
    fn taking<T>(
        logs: &'a Logs,
        worktree: &'a Path,
        outside: Box<dyn Outside>,
        steps: impl FnOnce(&Self) -> Result<T>,
    ) -> (Result<T>, Option<PathBuf>) {
        let sessions = Sessions {
            logs,
            worktree,
            outside: RefCell::new(outside),
            last_log: RefCell::default(),
            endings_with_killed_work: RefCell::default(),
        };
        let taken = steps(&sessions);
        let log = sessions.last_log.into_inner();
        let endings = sessions.endings_with_killed_work.into_inner();
        if endings.is_empty() {
            return (taken, log);
        }
        let taken = taken.map_err(|error| {
            error.context(format!("{}, and a later step failed", endings.join("; ")))
        });
        (taken, log)
    }

    /// Run a session given `prompt`, logged and labelled as `kind`. A
    /// session that ends with background work still running, which is killed
    /// with it, gets one Resume, as `<kind>-resume`. If the Resume ends the
    /// same way, or the session has no id to resume, the work may have been
    /// abandoned rather than awaited: a progress line names it and this
    /// succeeds, leaving the steps that follow to decide the outcome.
    pub fn run(&self, kind: &str, prompt: &str) -> Result<()> {
        // The shared review-report contract identifies code review sessions
        // before expansion. Retain that policy even if report setup fails.
        let purpose = if prompt.contains(prompt::REVIEW_REPORTS_DIRECTORY) {
            Purpose::CodeReview
        } else {
            Purpose::Ordinary
        };
        self.run_to_final_message(purpose, kind, prompt).map(drop)
    }

    /// Run a session as [`Sessions::run`] does, and return its final message:
    /// what the agent said as it ended its last turn, its Resume's if it got
    /// one, if anything. `purpose` selects the launch policy for both the
    /// session and its Resume; `kind` only labels their logs and progress.
    pub fn run_to_final_message(
        &self,
        purpose: Purpose,
        kind: &str,
        prompt: &str,
    ) -> Result<Option<String>> {
        let reports = if prompt.contains(prompt::REVIEW_REPORTS_DIRECTORY) {
            match ReviewReports::new(self.worktree) {
                Ok(reports) => Some(reports),
                Err(error) => {
                    self.step(format!(
                        "{kind}: could not prepare review reports: {error:#}; carrying on"
                    ));
                    None
                }
            }
        } else {
            None
        };
        let mut ended = self.start(purpose, kind, None, prompt, reports.as_ref())?;
        // What ended last, as the progress line on its killed work calls it.
        let mut ended_last = "session";
        if let (false, Some(session_id)) = (ended.killed.is_empty(), &ended.session_id) {
            let mut resume_prompt = prompt::resume(&ended.killed_work());
            if reports.is_some() {
                resume_prompt.push_str(prompt::REVIEW_REPORTS);
            }
            self.step(format!(
                "{kind}: background work was killed as the session ended; resuming it once"
            ));
            ended = self.start(
                purpose,
                &format!("{kind}-resume"),
                Some(session_id),
                &resume_prompt,
                reports.as_ref(),
            )?;
            ended_last = "Resume";
        }
        if !ended.killed.is_empty() {
            let ending = ending_with(&ended.killed_work());
            if matches!(
                purpose,
                Purpose::Security | Purpose::SecurityPublishing | Purpose::SecurityReview
            ) {
                return Err(anyhow!(
                    "{kind}: the {ended_last} {ending}; Security session incomplete"
                ));
            }
            self.step(format!(
                "{kind}: the {ended_last} {ending}; carrying on, as it may have been abandoned"
            ));
            self.endings_with_killed_work
                .borrow_mut()
                .push(format!("{kind} session {ending}"));
        }
        Ok(ended.final_message)
    }

    /// Run one session as `kind`, logged under its own path, which becomes
    /// the last log.
    fn start(
        &self,
        purpose: Purpose,
        kind: &str,
        resume: Option<&str>,
        prompt: &str,
        reports: Option<&ReviewReports>,
    ) -> Result<Ended> {
        let log = self.logs.path(kind)?;
        logs::session(kind, &log);
        self.step(format!("logging the session to {}", log.display()));
        *self.last_log.borrow_mut() = Some(log.clone());
        let prompt = reports.map_or_else(
            || prompt.replace(prompt::REVIEW_REPORTS, ""),
            |reports| reports.prompt(prompt),
        );
        let ended = self
            .outside
            .borrow_mut()
            .run_session(purpose, kind, resume, &prompt, &log);
        if let Some(reports) = reports {
            for line in reports.keep(&log) {
                self.step(format!("{kind}: {line}"));
            }
        }
        ended
    }

    /// Hand on the progress line `line`.
    fn step(&self, line: String) {
        self.outside.borrow_mut().step(line);
    }
}

/// How the Resume rules run a session, and where their progress lines go.
trait Outside {
    /// Run a session given `prompt`, labelled as `kind`, continuing the
    /// session with id `resume`, if any, its stream logged to `log`. Returns
    /// what it ended with once it has exited cleanly, its turn not failed.
    fn run_session(
        &mut self,
        purpose: Purpose,
        kind: &str,
        resume: Option<&str>,
        prompt: &str,
        log: &Path,
    ) -> Result<Ended>;
    /// Hand on the progress line `line`.
    fn step(&mut self, line: String);
}

/// Sessions run by the Command's `harness`'s CLI in `worktree`, where it
/// finds the Factory skills, and progress lines printed on stderr.
struct OnMachine {
    worktree: PathBuf,
    harness: Choice,
    adapter: &'static dyn Adapter,
    recording: recording::Recording,
}

impl Outside for OnMachine {
    fn run_session(
        &mut self,
        purpose: Purpose,
        kind: &str,
        resume: Option<&str>,
        prompt: &str,
        log: &Path,
    ) -> Result<Ended> {
        let interpretation = self.adapter.interpretation(&self.worktree, prompt);
        let (invocation, interpretation) = match purpose {
            Purpose::Security | Purpose::SecurityPublishing | Purpose::SecurityReview => (
                self.adapter.security_session(
                    &self.harness,
                    resume,
                    prompt,
                    matches!(purpose, Purpose::Security | Purpose::SecurityPublishing),
                ),
                interpretation.for_security(self.harness.model.as_deref()),
            ),
            Purpose::Ordinary => (
                self.adapter.session(&self.harness, resume, prompt),
                interpretation,
            ),
            Purpose::CodeReview => (
                self.adapter
                    .code_review_session(&self.harness, resume, prompt),
                interpretation,
            ),
        };
        let mut command = Command::new(self.adapter.name());
        command.args(&invocation.args).current_dir(&self.worktree);
        // Keep the empty config alive until the process and descendants
        // finish. A fresh one on each launch also fences the Resume.
        let github_config = if matches!(purpose, Purpose::SecurityPublishing) {
            let config = tempfile::Builder::new()
                .prefix("thirdshift-gh-")
                .tempdir()
                .context("could not prepare an unauthenticated GitHub config")?;
            for name in [
                "GH_TOKEN",
                "GITHUB_TOKEN",
                "GH_ENTERPRISE_TOKEN",
                "GITHUB_ENTERPRISE_TOKEN",
            ] {
                command.env_remove(name);
            }
            command
                .env("GH_CONFIG_DIR", config.path())
                .env("GH_PROMPT_DISABLED", "1");
            Some(config)
        } else {
            None
        };
        let ended = self.recording.run(
            kind,
            &self.harness,
            &mut command,
            invocation.stdin.as_deref(),
            log,
            interpretation,
        );
        drop(github_config);
        ended
    }

    fn step(&mut self, line: String) {
        progress::step(line);
    }
}

/// How a session ended whose `killed` background work, by description, was
/// still running: what follows "implement session" or "the Resume".
fn ending_with(killed: &[&str]) -> String {
    match killed {
        [task] => format!("ended with a background task still running ({task}), which was killed"),
        _ => format!(
            "ended with background tasks still running ({}), which were killed",
            killed.join("; ")
        ),
    }
}

#[cfg(test)]
mod tests {
    use std::collections::VecDeque;
    use std::rc::Rc;

    use anyhow::anyhow;

    use super::*;

    #[test]
    fn unsafe_project_skills_fail_session_start_before_the_harness_can_run() {
        for harness in crate::harness::Harness::ALL {
            let worktree = crate::test_support::scratch_worktree();
            let outside = tempfile::tempdir().unwrap();
            let dir = worktree.path().join(harness.adapter().project_skills());
            fs::create_dir_all(dir.parent().unwrap()).unwrap();
            std::os::unix::fs::symlink(outside.path(), &dir).unwrap();
            let logs = Logs {
                name: "670".into(),
                dir: worktree.path().join("session-logs"),
                recording: OnceCell::new(),
            };
            let choice = Choice {
                harness,
                ..Choice::default()
            };
            let (result, log) =
                Sessions::within(&logs, worktree.path(), &choice, |_| -> Result<()> {
                    panic!("unsafe project skills must prevent session steps");
                });
            let error = result.unwrap_err();
            assert!(format!("{error:#}").contains(&dir.display().to_string()));
            assert!(log.is_none());
            assert!(!logs.dir.exists());
            assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0);
        }
    }

    /// What the rules asked of the scripted [`Outside`], in order.
    #[derive(Debug, PartialEq)]
    enum Call {
        /// A session run, as `kind`, continuing `resume`, logged to `log`.
        Session {
            kind: String,
            resume: Option<String>,
            prompt: String,
            log: PathBuf,
        },
        /// A progress line handed on.
        Step(String),
    }

    /// Sessions that end as scripted, in turn, recording every call in
    /// `calls`.
    struct Scripted {
        endings: VecDeque<Result<Ended>>,
        calls: Rc<RefCell<Vec<Call>>>,
    }

    impl Outside for Scripted {
        fn run_session(
            &mut self,
            _purpose: Purpose,
            kind: &str,
            resume: Option<&str>,
            prompt: &str,
            log: &Path,
        ) -> Result<Ended> {
            self.calls.borrow_mut().push(Call::Session {
                kind: kind.to_string(),
                resume: resume.map(String::from),
                prompt: prompt.to_string(),
                log: log.to_path_buf(),
            });
            self.endings
                .pop_front()
                .expect("a session was run that was not scripted")
        }

        fn step(&mut self, line: String) {
            self.calls.borrow_mut().push(Call::Step(line));
        }
    }

    /// A session ending with id `session_id`, if any, with `killed` work
    /// and the final message `said`.
    fn ended(session_id: Option<&str>, killed: &[&str], said: &str) -> Result<Ended> {
        Ok(Ended {
            session_id: session_id.map(String::from),
            killed: killed.iter().map(|work| work.to_string()).collect(),
            final_message: Some(said.to_string()),
        })
    }

    /// The logs of the Run on issue 7, under `/logs`.
    fn logs() -> Logs {
        Logs {
            name: "7".to_string(),
            dir: PathBuf::from("/logs/sessions"),
            recording: recording::Recording::new(
                Some(PathBuf::from("/logs/7-20261003T120000-0400~fixture.log")),
                None,
                "20261003T120000-0400".into(),
            )
            .into(),
        }
    }

    #[test]
    fn same_second_commands_keep_session_model_and_review_contents_separate() {
        let root = crate::test_support::scratch_worktree();
        let stamp = "20261003T120000-0400";
        let mut captured = Vec::new();
        for marker in ["first", "second"] {
            let recording = recording::Recording::new(
                Some(root.path().join(format!("7-{stamp}~{marker}.log"))),
                None,
                stamp.into(),
            );
            let logs = Logs {
                name: "7".into(),
                dir: root.path().into(),
                recording: recording.clone().into(),
            };
            let log = logs.path("implement").unwrap();
            recording.run(
                "implement", &Choice::default(),
                Command::new("/bin/sh").args(["-c", &format!(
                    "echo '{{\"type\":\"assistant\",\"message\":{{\"model\":\"{marker}-model\",\"content\":[]}}}}'"
                )]), None, &log,
                crate::harness::Harness::Claude.adapter().interpretation(root.path(), ""),
            ).unwrap();
            let reports = ReviewReports::new(root.path()).unwrap();
            for axis in ["standards", "spec"] {
                fs::write(
                    Path::new(&reports.prompt(prompt::REVIEW_REPORTS_DIRECTORY))
                        .join(format!("{axis}.md")),
                    format!("{marker} {axis}"),
                )
                .unwrap();
            }
            assert!(reports.keep(&log).is_empty());
            captured.push((marker, log, recording));
        }
        for (marker, log, recording) in captured {
            assert!(
                fs::read_to_string(&log)
                    .unwrap()
                    .contains(&format!("{marker}-model")),
                "lost {marker} Session"
            );
            for axis in ["standards", "spec"] {
                assert_eq!(
                    fs::read_to_string(log.with_extension(axis)).unwrap(),
                    format!("{marker} {axis}")
                );
            }
            assert_eq!(
                recording.notification_lines(),
                [format!(
                    "- implement: claude · {marker}-model (reported) · session effort: default effort"
                )]
            );
        }
    }

    /// Take `steps` through sessions that end as `endings` script them.
    /// Returns what the steps came to, the last log, and every call made.
    fn take<T>(
        endings: Vec<Result<Ended>>,
        steps: impl FnOnce(&Sessions) -> Result<T>,
    ) -> (Result<T>, Option<PathBuf>, Vec<Call>) {
        let calls = Rc::new(RefCell::new(Vec::new()));
        let scripted = Scripted {
            endings: endings.into(),
            calls: Rc::clone(&calls),
        };
        let logs = logs();
        let (taken, log) = Sessions::taking(&logs, Path::new("/unused"), Box::new(scripted), steps);
        let calls = calls.take();
        (taken, log, calls)
    }

    /// The call that runs a session as `kind`, logged under its own path.
    fn session(kind: &str, resume: Option<&str>, prompt: &str) -> Call {
        Call::Session {
            kind: kind.to_string(),
            resume: resume.map(String::from),
            prompt: prompt.to_string(),
            log: logs().path(kind).unwrap(),
        }
    }

    /// The progress line saying where a session as `kind` is logged.
    fn logging(kind: &str) -> Call {
        Call::Step(format!(
            "logging the session to {}",
            logs().path(kind).unwrap().display()
        ))
    }

    /// The sessions among `calls`.
    fn sessions_run(calls: &[Call]) -> Vec<&Call> {
        calls
            .iter()
            .filter(|call| matches!(call, Call::Session { .. }))
            .collect()
    }

    /// How a session that left `cargo test` running is said to have ended.
    const TESTS_KILLED: &str =
        "ended with a background task still running (cargo test), which was killed";

    #[test]
    fn a_clean_session_runs_once_and_returns_its_final_message() {
        let (taken, log, calls) = take(vec![ended(Some("s1"), &[], "done")], |sessions| {
            sessions.run_to_final_message(Purpose::Ordinary, "implement", "do it")
        });

        assert_eq!(taken.unwrap(), Some("done".to_string()));
        assert_eq!(
            calls,
            [logging("implement"), session("implement", None, "do it")]
        );
        assert_eq!(log, Some(logs().path("implement").unwrap()));
    }

    #[test]
    fn a_session_whose_background_work_was_killed_is_resumed_once_on_its_id() {
        let (taken, log, calls) = take(
            vec![
                ended(Some("s1"), &["cargo test"], "waiting"),
                ended(Some("s1"), &[], "done"),
            ],
            |sessions| sessions.run_to_final_message(Purpose::Ordinary, "implement", "do it"),
        );

        assert_eq!(taken.unwrap(), Some("done".to_string()));
        assert_eq!(
            calls,
            [
                logging("implement"),
                session("implement", None, "do it"),
                Call::Step(
                    "implement: background work was killed as the session ended; resuming it once"
                        .to_string()
                ),
                logging("implement-resume"),
                session(
                    "implement-resume",
                    Some("s1"),
                    &prompt::resume(&["cargo test"])
                ),
            ]
        );
        assert_eq!(log, Some(logs().path("implement-resume").unwrap()));
    }

    #[test]
    fn a_security_session_cannot_claim_completion_with_killed_work() {
        for id in [None, Some("s1")] {
            let mut endings = vec![ended(
                id,
                &["cargo test"],
                "Security review: {\"unaddressed_count\":0,\"findings\":[]}",
            )];
            if id.is_some() {
                endings.push(ended(
                    id,
                    &["cargo test"],
                    "Security review: {\"unaddressed_count\":0,\"findings\":[]}",
                ));
            }
            let (taken, _, _) = take(endings, |sessions| {
                sessions.run_to_final_message(Purpose::Security, "security-review", "review")
            });
            assert!(
                taken.is_err(),
                "unfinished security work must hold Self-merge"
            );
        }
    }

    #[test]
    fn a_resume_that_ends_the_same_way_gets_no_second_and_a_later_failure_names_the_work() {
        let (taken, log, calls) = take(
            vec![
                ended(Some("s1"), &["cargo test"], "waiting"),
                ended(Some("s1"), &["cargo test"], "still waiting"),
            ],
            |sessions| -> Result<()> {
                assert_eq!(
                    sessions
                        .run_to_final_message(Purpose::Ordinary, "implement", "do it")
                        .unwrap(),
                    Some("still waiting".to_string())
                );
                bail!("no PR found")
            },
        );

        assert_eq!(sessions_run(&calls).len(), 2, "one Resume, never two");
        assert_eq!(
            calls.last(),
            Some(&Call::Step(format!(
                "implement: the Resume {TESTS_KILLED}; carrying on, as it may have been abandoned"
            )))
        );
        assert_eq!(
            format!("{:#}", taken.unwrap_err()),
            format!("implement session {TESTS_KILLED}, and a later step failed: no PR found")
        );
        assert_eq!(log, Some(logs().path("implement-resume").unwrap()));
    }

    #[test]
    fn a_session_with_no_id_to_resume_is_not_resumed_and_carries_on() {
        let (taken, log, calls) = take(
            vec![ended(None, &["cargo test", "npm test"], "waiting")],
            |sessions| sessions.run("implement", "do it"),
        );

        taken.unwrap();
        assert_eq!(
            calls,
            [
                logging("implement"),
                session("implement", None, "do it"),
                Call::Step(
                    "implement: the session ended with background tasks still running \
                     (cargo test; npm test), which were killed; carrying on, as it may have \
                     been abandoned"
                        .to_string()
                ),
            ]
        );
        assert_eq!(log, Some(logs().path("implement").unwrap()));
    }

    #[test]
    fn a_failing_session_fails_unchanged_without_a_resume_or_an_ending() {
        let (taken, log, calls) = take(vec![Err(anyhow!("claude exited 3"))], |sessions| {
            let failed = sessions.run("implement", "do it").unwrap_err();
            assert_eq!(format!("{failed:#}"), "claude exited 3");
            Err::<(), _>(failed)
        });

        // No ending was recorded, so the failure names no killed work.
        assert_eq!(format!("{:#}", taken.unwrap_err()), "claude exited 3");
        assert_eq!(
            calls,
            [logging("implement"), session("implement", None, "do it")]
        );
        // The failing session's log is still the last log.
        assert_eq!(log, Some(logs().path("implement").unwrap()));
    }

    #[test]
    fn steps_that_succeed_after_killed_work_come_to_what_they_came_to() {
        let (taken, _, _) = take(vec![ended(None, &["cargo test"], "waiting")], |sessions| {
            sessions.run("implement", "do it")?;
            Ok(42)
        });

        assert_eq!(taken.unwrap(), 42);
    }

    #[test]
    fn every_session_that_left_killed_work_is_named_oldest_first() {
        let (taken, _, calls) = take(
            vec![
                ended(None, &["cargo test"], "waiting"),
                ended(None, &["npm test"], "waiting"),
            ],
            |sessions| -> Result<()> {
                sessions.run("implement", "do it")?;
                sessions.run("repair-1", "fix it")?;
                bail!("CI failed")
            },
        );

        assert_eq!(sessions_run(&calls).len(), 2);
        assert_eq!(
            format!("{:#}", taken.unwrap_err()),
            format!(
                "implement session {TESTS_KILLED}; repair-1 session ended with a background \
                 task still running (npm test), which was killed, and a later step failed: \
                 CI failed"
            )
        );
    }

    #[test]
    fn a_prompt_that_loads_a_skill_loads_it_with_codexs_sigil_on_codex() {
        let issue = IssueUrl::parse("https://github.com/acme/widgets/issues/7").unwrap();
        for (prompt, first_line) in [
            (
                prompt::fresh(&issue, "main", "issue-7"),
                "$thirdshift-implement https://github.com/acme/widgets/issues/7",
            ),
            (
                prompt::conflict_repair(&issue, "main", "issue-7", "<pr>"),
                "$thirdshift-resolving-merge-conflicts",
            ),
            (
                prompt::review_repair(&issue, "issue-7", "<pr>", "abc123"),
                "$thirdshift-code-review abc123",
            ),
            (
                prompt::resume(&["cargo test"]),
                "Your background work (cargo test) was killed when your turn ended, because \
                 ending the turn ends the session.",
            ),
        ] {
            let codex_prompt = codex_prompt(&prompt);
            assert_eq!(codex_prompt.lines().next(), Some(first_line));
            assert_eq!(codex_prompt[1..], prompt[1..]);
        }
    }
}