1use serde::{Deserialize, Serialize};
10
11use crate::{AgentEvent, RosterSlot, ToolStatus, ToolUpdate};
12
13#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16pub enum PairRole {
17 Implementer,
19 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
32pub 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
44pub 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#[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 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#[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 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 pub fn reset(&mut self) {
137 let task = self.task.take();
138 *self = Self::default();
139 self.task = task;
140 }
141
142 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 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 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 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 pub fn render_markdown(&self) -> String {
251 self.render(true)
252 }
253
254 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 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 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}