Skip to main content

codeswarm_adapters/
workflow.rs

1//! Pure workflow semantics for pair collaboration and completion evidence.
2//!
3//! This module has no filesystem or provider I/O. Role helpers classify
4//! implementer/reviewer handoffs, and [`CompletionSummary`] accumulates a
5//! result summary strictly from observed [`AgentEvent`] data. Agent prose is
6//! labelled as a claim, never as evidence, and missing evidence is labelled
7//! unknown.
8
9use serde::{Deserialize, Serialize};
10
11use crate::{AgentEvent, RosterSlot, ToolStatus, ToolUpdate};
12
13/// A role inside the two-agent pair review loop. Roster, solo, and manual
14/// strategies never assign these roles.
15#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16pub enum PairRole {
17    /// The agent producing the change that the pair reviewer will inspect.
18    Implementer,
19    /// The agent inspecting an implementer's handoff for concrete defects.
20    Reviewer,
21}
22
23impl PairRole {
24    pub fn label(self) -> &'static str {
25        match self {
26            PairRole::Implementer => "Implementer",
27            PairRole::Reviewer => "Reviewer",
28        }
29    }
30}
31
32/// Classify a non-direct pair-strategy dispatch.
33///
34/// Roles are anchored to the first responder of the current human task, so
35/// returning to that slot after review means implementation work again.
36pub fn pair_role(implementer_slot: Option<RosterSlot>, slot: RosterSlot) -> Option<PairRole> {
37    match implementer_slot {
38        None => Some(PairRole::Implementer),
39        Some(implementer) if implementer == slot => Some(PairRole::Implementer),
40        Some(_) => Some(PairRole::Reviewer),
41    }
42}
43
44/// Explain the pair handoff for one dispatched turn.
45///
46/// `peer` optionally names the counterpart agent. The reviewer fragment asks
47/// for concrete defects or a concise approval; the implementer fragment makes
48/// the upcoming review explicit. Neither fragment mentions the stop token, so
49/// stop eligibility stays governed by the existing prompt footer.
50pub fn role_fragment(role: PairRole, peer: Option<&str>) -> String {
51    match role {
52        PairRole::Implementer => match peer {
53            Some(peer) => format!(
54                "Pair role: you are the implementer. Produce the concrete change for the \
55                 shared task; your reviewer {peer} will review the result next, so describe \
56                 exactly what you changed."
57            ),
58            None => "Pair role: you are the implementer. Produce the concrete change for the \
59                 shared task; your pair reviewer will review the result next, so describe \
60                 exactly what you changed."
61                .to_owned(),
62        },
63        PairRole::Reviewer => match peer {
64            Some(peer) => format!(
65                "Pair role: you are the reviewer. {peer} handed off their work for review. \
66                 Reply with concrete defects to fix, or a concise approval if none remain."
67            ),
68            None => "Pair role: you are the reviewer. The implementer handed off their work for \
69                 review. Reply with concrete defects to fix, or a concise approval if none \
70                 remain."
71                .to_owned(),
72        },
73    }
74}
75
76/// One deduplicated tool outcome, keyed by the adapter's stable tool id.
77#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
78pub struct ToolOutcome {
79    pub id: String,
80    pub title: String,
81    pub status: ToolStatus,
82    pub detail: Option<String>,
83    pub slot: RosterSlot,
84}
85
86impl ToolOutcome {
87    /// Evidence label for the final observed status. A pending or running
88    /// tool has no final outcome, so its evidence is unknown; a completed
89    /// status proves only that the tool call finished, never that a test
90    /// suite or any claimed result succeeded.
91    pub fn evidence_label(&self) -> &'static str {
92        match self.status {
93            ToolStatus::Completed => "completed",
94            ToolStatus::Failed => "failed",
95            ToolStatus::Running => "unknown (still running)",
96            ToolStatus::Pending => "unknown (still pending)",
97        }
98    }
99}
100
101/// Accumulated, evidence-bound completion state for one human task.
102///
103/// The root CLI resets this per human task, feeds live [`AgentEvent`]s as
104/// they arrive, attaches caller-supplied working-tree paths, and renders the
105/// `/summary` text. [`AgentEvent::History`] replays are display-only and are
106/// never counted as live turns; use [`CompletionSummary::from_events`] to
107/// rebuild from archived events deliberately.
108#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
109pub struct CompletionSummary {
110    task: Option<String>,
111    response: Option<String>,
112    response_slot: Option<RosterSlot>,
113    pending_response: String,
114    pending_slot: Option<RosterSlot>,
115    tools: Vec<ToolOutcome>,
116    changed_paths: Vec<String>,
117    changed_paths_observed: bool,
118    turns_observed: usize,
119}
120
121impl CompletionSummary {
122    pub fn new() -> Self {
123        Self::default()
124    }
125
126    /// Start a fresh task. Clears all observed state and records the task
127    /// text shown at the top of every render.
128    pub fn begin_task(&mut self, task: impl Into<String>) {
129        *self = Self {
130            task: Some(task.into()),
131            ..Self::default()
132        };
133    }
134
135    /// Clear all observed state without changing the task text.
136    pub fn reset(&mut self) {
137        let task = self.task.take();
138        *self = Self::default();
139        self.task = task;
140    }
141
142    /// Observe one live normalized event. Display-only history replay events
143    /// ([`AgentEvent::History`]) are ignored so restored conversations never
144    /// count as live turns or fabricate outcomes.
145    pub fn observe(&mut self, event: &AgentEvent) {
146        match event {
147            AgentEvent::History { .. } | AgentEvent::BatchComplete { .. } => {}
148            AgentEvent::TurnStarted { .. } => {
149                self.commit_pending();
150                self.turns_observed += 1;
151            }
152            AgentEvent::Text { slot, text } => {
153                self.pending_response.push_str(text);
154                self.pending_slot = Some(*slot);
155            }
156            AgentEvent::Tool { slot, update } => self.record_tool(*slot, update),
157            AgentEvent::TurnComplete { .. }
158            | AgentEvent::Failed { .. }
159            | AgentEvent::UsageLimitReached { .. } => self.commit_pending(),
160            _ => {}
161        }
162    }
163
164    /// Deliberately rebuild a summary from stored or replayed events, such as
165    /// an archived journal. Unlike live observation this is an explicit
166    /// intent to summarize past activity; [`AgentEvent::History`] wrappers
167    /// are still display-only and stay excluded.
168    pub fn from_events<'a>(events: impl IntoIterator<Item = &'a AgentEvent>) -> Self {
169        let mut summary = Self::new();
170        for event in events {
171            summary.observe(event);
172        }
173        summary
174    }
175
176    /// Attach caller-supplied changed working-tree paths, such as `git
177    /// status` output. The summary never inspects the filesystem itself.
178    /// Paths are trimmed, deduplicated preserving first-seen order, and
179    /// replace any previously attached set.
180    pub fn set_changed_paths(&mut self, paths: impl IntoIterator<Item = impl Into<String>>) {
181        let mut seen = Vec::new();
182        for path in paths {
183            let path = path.into().trim().to_owned();
184            if !path.is_empty() && !seen.contains(&path) {
185                seen.push(path);
186            }
187        }
188        self.changed_paths = seen;
189        self.changed_paths_observed = true;
190    }
191
192    pub fn task(&self) -> Option<&str> {
193        self.task.as_deref()
194    }
195
196    /// The final response text of the most recent turn with text. This is
197    /// agent-reported prose, never evidence of execution.
198    pub fn last_response(&self) -> Option<&str> {
199        self.response.as_deref()
200    }
201
202    pub fn last_response_slot(&self) -> Option<RosterSlot> {
203        self.response_slot
204    }
205
206    pub fn tool_outcomes(&self) -> &[ToolOutcome] {
207        &self.tools
208    }
209
210    pub fn changed_paths(&self) -> &[String] {
211        &self.changed_paths
212    }
213
214    pub fn observed_turns(&self) -> usize {
215        self.turns_observed
216    }
217
218    fn commit_pending(&mut self) {
219        if !self.pending_response.trim().is_empty() {
220            self.response = Some(std::mem::take(&mut self.pending_response));
221            self.response_slot = self.pending_slot.take();
222        } else {
223            self.pending_response.clear();
224            self.pending_slot = None;
225        }
226    }
227
228    fn record_tool(&mut self, slot: RosterSlot, update: &ToolUpdate) {
229        if let Some(existing) = self
230            .tools
231            .iter_mut()
232            .find(|outcome| outcome.slot == slot && outcome.id == update.id)
233        {
234            existing.title = update.title.clone();
235            existing.status = update.status;
236            existing.detail = update.detail.clone();
237            existing.slot = slot;
238        } else {
239            self.tools.push(ToolOutcome {
240                id: update.id.clone(),
241                title: update.title.clone(),
242                status: update.status,
243                detail: update.detail.clone(),
244                slot,
245            });
246        }
247    }
248
249    /// Render the summary as GitHub-flavored Markdown.
250    pub fn render_markdown(&self) -> String {
251        self.render(true)
252    }
253
254    /// Render the summary as plain text.
255    pub fn render_text(&self) -> String {
256        self.render(false)
257    }
258
259    fn render(&self, markdown: bool) -> String {
260        let mut out = String::new();
261        if markdown {
262            out.push_str("## Completion summary\n\n");
263        } else {
264            out.push_str("Completion summary\n\n");
265        }
266        match &self.task {
267            Some(task) => {
268                if markdown {
269                    out.push_str(&format!("**Task:** {task}\n"));
270                } else {
271                    out.push_str(&format!("Task: {task}\n"));
272                }
273            }
274            None => {
275                if markdown {
276                    out.push_str("_Task: unknown (no task recorded)._\n");
277                } else {
278                    out.push_str("Task: unknown (no task recorded).\n");
279                }
280            }
281        }
282        out.push('\n');
283        match &self.response {
284            Some(response) => {
285                if markdown {
286                    out.push_str("**Last response** (agent-reported, not evidence):\n");
287                } else {
288                    out.push_str("Last response (agent-reported, not evidence):\n");
289                }
290                out.push_str(response.trim_end());
291                out.push_str("\n\n");
292            }
293            None => {
294                if markdown {
295                    out.push_str("_Last response: unknown (no live response observed)._\n\n");
296                } else {
297                    out.push_str("Last response: unknown (no live response observed).\n\n");
298                }
299            }
300        }
301        if markdown {
302            out.push_str("**Tool outcomes:**\n");
303        } else {
304            out.push_str("Tool outcomes:\n");
305        }
306        if self.tools.is_empty() {
307            if markdown {
308                out.push_str("_None recorded; execution evidence is unknown._\n");
309            } else {
310                out.push_str("None recorded; execution evidence is unknown.\n");
311            }
312        } else {
313            for outcome in &self.tools {
314                out.push_str(&format!(
315                    "- {} (`{}`, slot {}): {}",
316                    outcome.title,
317                    outcome.id,
318                    outcome.slot,
319                    outcome.evidence_label()
320                ));
321                if let Some(detail) = &outcome.detail {
322                    for line in detail.lines() {
323                        if markdown {
324                            out.push_str(&format!("\n  > {line}"));
325                        } else {
326                            out.push_str(&format!("\n    {line}"));
327                        }
328                    }
329                }
330                out.push('\n');
331            }
332        }
333        out.push('\n');
334        if markdown {
335            out.push_str("**Changed working-tree paths** (caller-provided):\n");
336        } else {
337            out.push_str("Changed working-tree paths (caller-provided):\n");
338        }
339        if self.changed_paths.is_empty() && self.changed_paths_observed {
340            out.push_str("None (working tree was clean when checked).\n");
341        } else if self.changed_paths.is_empty() {
342            if markdown {
343                out.push_str("_Unknown (not provided)._\n");
344            } else {
345                out.push_str("Unknown (not provided).\n");
346            }
347        } else {
348            for path in &self.changed_paths {
349                out.push_str(&format!("- {path}\n"));
350            }
351        }
352        out
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::{CompletionSummary, PairRole, ToolOutcome, pair_role, role_fragment};
359    use crate::{AgentEvent, ToolStatus, ToolUpdate};
360
361    fn tool(id: &str, status: ToolStatus) -> AgentEvent {
362        AgentEvent::Tool {
363            slot: 1,
364            update: ToolUpdate {
365                id: id.into(),
366                title: format!("tool {id}"),
367                status,
368                detail: None,
369            },
370        }
371    }
372
373    #[test]
374    fn pair_roles_classify_fresh_handoffs_and_repeat_dispatches() {
375        assert_eq!(pair_role(None, 0), Some(PairRole::Implementer));
376        assert_eq!(pair_role(None, 3), Some(PairRole::Implementer));
377        assert_eq!(pair_role(Some(0), 1), Some(PairRole::Reviewer));
378        assert_eq!(pair_role(Some(1), 0), Some(PairRole::Reviewer));
379        assert_eq!(pair_role(Some(0), 0), Some(PairRole::Implementer));
380    }
381
382    #[test]
383    fn role_fragments_explain_handoffs_and_request_defects_or_approval() {
384        let implementer = role_fragment(PairRole::Implementer, None);
385        assert!(implementer.contains("you are the implementer"));
386        assert!(implementer.contains("pair reviewer will review the result next"));
387        assert!(!implementer.contains(crate::relay::STOP_TOKEN));
388
389        let reviewer = role_fragment(PairRole::Reviewer, Some("Claude"));
390        assert!(reviewer.contains("you are the reviewer"));
391        assert!(reviewer.contains("Claude handed off"));
392        assert!(reviewer.contains("concrete defects"));
393        assert!(reviewer.contains("concise approval"));
394        assert!(!reviewer.contains(crate::relay::STOP_TOKEN));
395
396        let unnamed = role_fragment(PairRole::Reviewer, None);
397        assert!(unnamed.contains("The implementer handed off"));
398    }
399
400    #[test]
401    fn summary_accumulates_only_the_latest_turn_response() {
402        let mut summary = CompletionSummary::new();
403        summary.begin_task("fix the build");
404        for event in [
405            AgentEvent::TurnStarted { slot: 0 },
406            AgentEvent::Text {
407                slot: 0,
408                text: "first ".into(),
409            },
410            AgentEvent::Text {
411                slot: 0,
412                text: "response".into(),
413            },
414            AgentEvent::TurnComplete { slot: 0 },
415            AgentEvent::TurnStarted { slot: 1 },
416            AgentEvent::Text {
417                slot: 1,
418                text: "final response".into(),
419            },
420            AgentEvent::TurnComplete { slot: 1 },
421        ] {
422            summary.observe(&event);
423        }
424        assert_eq!(summary.task(), Some("fix the build"));
425        assert_eq!(summary.last_response(), Some("final response"));
426        assert_eq!(summary.last_response_slot(), Some(1));
427        assert_eq!(summary.observed_turns(), 2);
428
429        // A turn that produces no text keeps the previous response.
430        summary.observe(&AgentEvent::TurnStarted { slot: 0 });
431        summary.observe(&AgentEvent::TurnComplete { slot: 0 });
432        assert_eq!(summary.last_response(), Some("final response"));
433    }
434
435    #[test]
436    fn summary_deduplicates_tool_updates_by_id() {
437        let mut summary = CompletionSummary::new();
438        summary.observe(&tool("t1", ToolStatus::Running));
439        summary.observe(&AgentEvent::Tool {
440            slot: 1,
441            update: ToolUpdate {
442                id: "t1".into(),
443                title: "cargo test".into(),
444                status: ToolStatus::Completed,
445                detail: Some("exit 0".into()),
446            },
447        });
448        summary.observe(&tool("t2", ToolStatus::Failed));
449        assert_eq!(
450            summary.tool_outcomes(),
451            [
452                ToolOutcome {
453                    id: "t1".into(),
454                    title: "cargo test".into(),
455                    status: ToolStatus::Completed,
456                    detail: Some("exit 0".into()),
457                    slot: 1,
458                },
459                ToolOutcome {
460                    id: "t2".into(),
461                    title: "tool t2".into(),
462                    status: ToolStatus::Failed,
463                    detail: None,
464                    slot: 1,
465                },
466            ]
467        );
468        let text = summary.render_text();
469        assert!(text.contains("cargo test (`t1`, slot 1): completed"));
470        assert!(text.contains("tool t2 (`t2`, slot 1): failed"));
471    }
472
473    #[test]
474    fn tool_ids_are_scoped_to_agents_and_explicit_empty_evidence_clears() {
475        let mut summary = CompletionSummary::new();
476        for slot in [0, 1] {
477            summary.observe(&AgentEvent::Tool {
478                slot,
479                update: ToolUpdate {
480                    id: "same".into(),
481                    title: "Read".into(),
482                    status: ToolStatus::Completed,
483                    detail: Some("output".into()),
484                },
485            });
486        }
487        assert_eq!(summary.tool_outcomes().len(), 2);
488        summary.observe(&AgentEvent::Tool {
489            slot: 0,
490            update: ToolUpdate {
491                id: "same".into(),
492                title: "Read".into(),
493                status: ToolStatus::Completed,
494                detail: None,
495            },
496        });
497        assert_eq!(summary.tool_outcomes()[0].detail, None);
498        assert_eq!(summary.tool_outcomes()[1].detail.as_deref(), Some("output"));
499        summary.set_changed_paths(Vec::<String>::new());
500        assert!(summary.render_text().contains("working tree was clean"));
501    }
502
503    #[test]
504    fn unresolved_tools_report_unknown_evidence() {
505        let mut summary = CompletionSummary::new();
506        summary.observe(&tool("t1", ToolStatus::Pending));
507        summary.observe(&tool("t2", ToolStatus::Running));
508        let text = summary.render_text();
509        assert!(text.contains("(`t1`, slot 1): unknown (still pending)"));
510        assert!(text.contains("(`t2`, slot 1): unknown (still running)"));
511        assert!(!text.to_lowercase().contains("completed"));
512    }
513
514    #[test]
515    fn history_events_never_count_as_live_turns_or_outcomes() {
516        let mut summary = CompletionSummary::new();
517        summary.begin_task("task");
518        summary.observe(&AgentEvent::History {
519            slot: 0,
520            content: crate::HistoryContent::Text("replayed".into()),
521        });
522        summary.observe(&AgentEvent::History {
523            slot: 0,
524            content: crate::HistoryContent::Tool(ToolUpdate {
525                id: "h1".into(),
526                title: "replayed tool".into(),
527                status: ToolStatus::Completed,
528                detail: None,
529            }),
530        });
531        assert_eq!(summary.last_response(), None);
532        assert!(summary.tool_outcomes().is_empty());
533        assert_eq!(summary.observed_turns(), 0);
534        let text = summary.render_text();
535        assert!(text.contains("unknown (no live response observed)"));
536        assert!(text.contains("None recorded; execution evidence is unknown."));
537    }
538
539    #[test]
540    fn from_events_rebuilds_archived_activity_deliberately() {
541        let events = vec![
542            AgentEvent::TurnStarted { slot: 0 },
543            AgentEvent::Text {
544                slot: 0,
545                text: "archived answer".into(),
546            },
547            AgentEvent::Tool {
548                slot: 0,
549                update: ToolUpdate {
550                    id: "a1".into(),
551                    title: "edit".into(),
552                    status: ToolStatus::Completed,
553                    detail: None,
554                },
555            },
556            AgentEvent::TurnComplete { slot: 0 },
557            // Display-only replay content stays excluded even here.
558            AgentEvent::History {
559                slot: 0,
560                content: crate::HistoryContent::Text("replay".into()),
561            },
562        ];
563        let summary = CompletionSummary::from_events(&events);
564        assert_eq!(summary.last_response(), Some("archived answer"));
565        assert_eq!(summary.tool_outcomes().len(), 1);
566        assert_eq!(summary.observed_turns(), 1);
567    }
568
569    #[test]
570    fn changed_paths_are_trimmed_deduplicated_and_rendered() {
571        let mut summary = CompletionSummary::new();
572        summary.set_changed_paths([" src/lib.rs ", "", "src/lib.rs", "docs/plan.md"]);
573        assert_eq!(
574            summary.changed_paths(),
575            ["src/lib.rs".to_string(), "docs/plan.md".to_string()]
576        );
577        let markdown = summary.render_markdown();
578        assert_eq!(markdown.matches("- src/lib.rs").count(), 1);
579        assert!(markdown.contains("- docs/plan.md"));
580
581        summary.set_changed_paths(Vec::<String>::new());
582        assert!(summary.changed_paths().is_empty());
583        assert!(
584            summary
585                .render_text()
586                .contains("None (working tree was clean when checked).")
587        );
588        assert!(
589            summary
590                .render_markdown()
591                .contains("None (working tree was clean when checked).")
592        );
593    }
594
595    #[test]
596    fn agent_prose_is_never_promoted_to_execution_evidence() {
597        let mut summary = CompletionSummary::new();
598        summary.observe(&AgentEvent::TurnStarted { slot: 0 });
599        summary.observe(&AgentEvent::Text {
600            slot: 0,
601            text: "All tests passed and the build is clean.".into(),
602        });
603        summary.observe(&AgentEvent::TurnComplete { slot: 0 });
604        for render in [summary.render_text(), summary.render_markdown()] {
605            assert!(render.contains("agent-reported, not evidence"));
606            assert!(render.contains("All tests passed"));
607            assert!(!render.to_lowercase().contains("verified"));
608            assert!(render.contains("None recorded; execution evidence is unknown."));
609        }
610    }
611
612    #[test]
613    fn renders_cover_task_absent_and_reset_semantics() {
614        let mut summary = CompletionSummary::new();
615        assert!(summary.render_text().contains("Task: unknown"));
616        summary.begin_task("first");
617        summary.observe(&AgentEvent::Text {
618            slot: 0,
619            text: "progress".into(),
620        });
621        summary.reset();
622        assert_eq!(summary.task(), Some("first"));
623        assert_eq!(summary.last_response(), None);
624        assert_eq!(summary.observed_turns(), 0);
625        assert!(summary.tool_outcomes().is_empty());
626        summary.begin_task("second");
627        assert_eq!(summary.task(), Some("second"));
628        assert_eq!(summary.last_response(), None);
629    }
630}