1use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use std::time::SystemTime;
12
13use forge_agent::client::SessionLaunchSettings;
14use forge_primitives::{
15 ContentBlock, HandoffBrief, HandoffCycle, HandoffGitState, HandoffReason, HandoffRecord,
16 HandoffSection, Message, Task, TaskStatus,
17};
18
19use crate::SessionSlot;
20use crate::protocol::{Command, NoticeSeverity, SessionUpdate};
21
22const FILE_CAP: usize = 10;
25const DIRTY_CAP: usize = 20;
26const JUDGMENT_CAP: usize = 5_000;
29
30#[derive(Debug, Clone, PartialEq, Eq)]
33pub(crate) enum Role {
34 Lead,
35 Worker { charter_opening: Option<String> },
36}
37
38pub(crate) struct HandoffSeat<'a> {
40 pub slot: &'a SessionSlot,
41 pub from: &'a str,
42 pub cwd: &'a Path,
43 pub transcript_path: &'a Path,
44 pub role: Role,
45}
46
47pub(crate) struct GroundTruth {
49 pub sections: Vec<HandoffSection>,
50 pub cycle: HandoffCycle,
51 pub reads: Vec<String>,
54}
55
56pub(crate) const SUCCESSOR_TURNS: u32 = 10;
58
59pub(crate) const JUDGMENT_DEADLINE: std::time::Duration = std::time::Duration::from_secs(600);
66
67const READ_SET_CAP: usize = 200;
71
72pub(crate) async fn ground_truth(
75 config_dir: &Path,
76 seat: &HandoffSeat<'_>,
77 board: &[Task],
78 live: Vec<HandoffSection>,
79) -> GroundTruth {
80 let read = {
81 let config_dir = config_dir.to_path_buf();
82 let session_id = seat.from.to_owned();
83 let cwd = seat.cwd.to_path_buf();
84 tokio::task::spawn_blocking(move || {
90 forge_agent::session_history_main_thread(
91 &config_dir,
92 &session_id,
93 &cwd.to_string_lossy(),
94 )
95 })
96 .await
97 };
98 let read_failed = read.is_err() || !seat.transcript_path.exists();
102 let messages = read.map(|history| history.messages).unwrap_or_default();
103 let git = forge_agent::env::git_state::probe(seat.cwd).await;
104 let memory_roots = memory_roots(config_dir, seat.cwd);
109 let mut truth = assemble(seat, &messages, &git, board, &memory_roots, live);
110 if read_failed {
111 truth.sections.insert(
116 0,
117 HandoffSection {
118 label: "note".to_owned(),
119 lines: vec![
120 "the previous session's transcript could not be read; the files this brief \
121 would list cannot be read for that reason"
122 .to_owned(),
123 ],
124 },
125 );
126 }
127 truth
128}
129
130pub(crate) fn assemble(
132 seat: &HandoffSeat<'_>,
133 messages: &[Message],
134 git: &HandoffGitState,
135 board: &[Task],
136 memory_roots: &[PathBuf],
137 live: Vec<HandoffSection>,
138) -> GroundTruth {
139 let turns = user_prompt_count(messages);
140 let mut sections = seat_sections(seat, git);
141 sections.push(role_section(&seat.role));
142 sections.push(files_section(messages, git, memory_roots));
143 sections.push(board_section(seat.slot, board));
144 sections.extend(live);
145 sections.push(record_section(seat));
146 GroundTruth { sections, cycle: cycle(messages, turns), reads: read_set(messages, memory_roots) }
147}
148
149fn memory_roots(config_dir: &Path, cwd: &Path) -> Vec<PathBuf> {
156 let project = forge_agent::userdata::memory::project_memory_path(config_dir, cwd);
157 [project.parent().map(Path::to_path_buf), Some(config_dir.join("memory"))]
158 .into_iter()
159 .flatten()
160 .collect()
161}
162
163fn is_memory_path(path: &str, memory_roots: &[PathBuf]) -> bool {
169 let forms = |path: &Path| {
173 let spelled = path.to_string_lossy().into_owned();
174 let expanded = match spelled.strip_prefix("~/") {
175 Some(rest) => dirs::home_dir().map_or_else(
176 || spelled.clone(),
177 |home| home.join(rest).to_string_lossy().into_owned(),
178 ),
179 None => spelled.clone(),
180 };
181 let canonical = std::fs::canonicalize(path)
182 .or_else(|_| std::fs::canonicalize(&expanded))
183 .map(|path| path.to_string_lossy().into_owned())
184 .ok();
185 let mut forms = vec![spelled, expanded];
186 if let Some(canonical) = canonical {
187 forms.push(canonical);
188 }
189 forms
190 };
191 let given = forms(Path::new(path));
192 memory_roots.iter().any(|root| {
193 let roots = forms(root);
194 roots.iter().any(|root| {
195 given.iter().any(|path| {
196 path == root
197 || path.strip_prefix(root.as_str()).is_some_and(|rest| rest.starts_with('/'))
198 })
199 })
200 })
201}
202
203fn read_set(messages: &[Message], memory_roots: &[PathBuf]) -> Vec<String> {
205 let mut reads: Vec<String> = Vec::new();
206 for (_, name, input) in tool_uses(messages) {
207 if !matches!(name, "Read" | "NotebookRead") {
208 continue;
209 }
210 let Some(path) = input.get("file_path").or_else(|| input.get("notebook_path")) else {
211 continue;
212 };
213 let Some(path) = path.as_str() else { continue };
214 if is_memory_path(path, memory_roots) {
215 continue;
216 }
217 if !reads.iter().any(|seen| seen == path) {
218 reads.push(path.to_owned());
219 }
220 if reads.len() >= READ_SET_CAP {
221 break;
222 }
223 }
224 reads
225}
226
227pub(crate) fn successor_counts(
235 messages: &[Message],
236 predecessor_reads: &[String],
237) -> forge_primitives::HandoffSuccessor {
238 let mut turns = 0;
239 let mut reads: Vec<String> = Vec::new();
240 let mut questions = 0;
241 let mut corrections = 0;
242 for message in messages {
243 match message {
244 Message::User { message: envelope, synthetic, .. } if !*synthetic => {
245 let Some(text) = text_of(&envelope.content) else { continue };
246 if is_machine_prompt(message) {
251 continue;
252 }
253 if turns >= SUCCESSOR_TURNS {
257 break;
258 }
259 turns += 1;
260 if turns > 1 && states_a_correction(&text) {
261 corrections += 1;
262 }
263 }
264 Message::Assistant { message: envelope, .. } => {
265 for block in &envelope.content {
266 let ContentBlock::ToolUse { name, input, .. } = block else { continue };
267 match name.as_str() {
268 "AskUserQuestion" => questions += 1,
269 "Read" | "NotebookRead" => {
270 let path = input
271 .get("file_path")
272 .or_else(|| input.get("notebook_path"))
273 .and_then(serde_json::Value::as_str);
274 if let Some(path) = path
275 && predecessor_reads.iter().any(|read| read == path)
276 && !reads.iter().any(|seen| seen == path)
277 {
278 reads.push(path.to_owned());
279 }
280 }
281 _ => {}
282 }
283 }
284 }
285 _ => {}
286 }
287 }
288 forge_primitives::HandoffSuccessor {
289 turns,
290 reread_files: u32::try_from(reads.len()).unwrap_or(u32::MAX),
291 questions,
292 corrections,
293 }
294}
295
296fn successor_measurement(
303 messages: &[Message],
304 predecessor_reads: &[String],
305) -> Option<forge_primitives::HandoffSuccessor> {
306 if messages.is_empty() {
307 return None;
308 }
309 let counts = successor_counts(messages, predecessor_reads);
310 (counts.turns > 0).then_some(counts)
314}
315
316fn states_a_correction(text: &str) -> bool {
318 let lower = text.to_lowercase();
319 [
320 "actually",
321 "i said",
322 "as i said",
323 "not what i asked",
324 "that is wrong",
325 "that's wrong",
326 "you missed",
327 "you forgot",
328 ]
329 .iter()
330 .any(|cue| lower.contains(cue))
331}
332
333fn seat_sections(seat: &HandoffSeat<'_>, git: &HandoffGitState) -> Vec<HandoffSection> {
336 let mut sections =
337 vec![fact("seat", seat.slot.display()), fact("cwd", seat.cwd.display().to_string())];
338 sections.push(match (git.branch.as_deref(), git.head.as_deref()) {
339 (Some(branch), Some(head)) => fact("branch", format!("{branch} @ {head}")),
340 (Some(branch), None) => fact("branch", branch.to_owned()),
341 (None, _) => fact("branch", "not a git repository".to_owned()),
342 });
343 sections
344}
345
346fn fact(label: &str, line: String) -> HandoffSection {
347 HandoffSection { label: label.to_owned(), lines: vec![line] }
348}
349
350fn role_section(role: &Role) -> HandoffSection {
351 let lines = match role {
352 Role::Lead => vec!["project lead".to_owned()],
353 Role::Worker { charter_opening } => match charter_opening {
354 Some(opening) => vec![format!("worker, charter opens: {opening}")],
355 None => vec!["worker".to_owned()],
356 },
357 };
358 HandoffSection { label: "role".to_owned(), lines }
359}
360
361fn files_section(
362 messages: &[Message],
363 git: &HandoffGitState,
364 memory_roots: &[PathBuf],
365) -> HandoffSection {
366 let mut changed: Vec<String> = Vec::new();
367 let mut read_paths: Vec<String> = Vec::new();
368 for (_, name, input) in tool_uses(messages) {
369 let Some(path) = input.get("file_path").or_else(|| input.get("notebook_path")) else {
370 continue;
371 };
372 let Some(path) = path.as_str() else { continue };
373 if is_memory_path(path, memory_roots) {
374 continue;
375 }
376 let bucket = match name {
377 "Edit" | "Write" | "NotebookEdit" | "MultiEdit" => &mut changed,
378 "Read" | "NotebookRead" => &mut read_paths,
379 _ => continue,
380 };
381 if !bucket.iter().any(|seen| seen == path) {
382 bucket.push(path.to_owned());
383 }
384 }
385 let mut lines = Vec::new();
386 if !changed.is_empty() {
387 lines.push(list_line("changed", &changed, FILE_CAP));
388 }
389 if !read_paths.is_empty() {
390 lines.push(list_line("read", &read_paths, FILE_CAP));
391 }
392 if git.dirty.is_empty() {
393 lines.push("uncommitted: none".to_owned());
394 } else {
395 let shown = tail(&git.dirty, DIRTY_CAP);
396 let total = if git.dirty_total > 0 {
400 usize::try_from(git.dirty_total).unwrap_or(usize::MAX)
401 } else {
402 git.dirty.len()
403 };
404 let omitted = total.saturating_sub(shown.len());
405 if omitted > 0 {
406 lines.push(format!(
407 "uncommitted: {total} paths, newest {} shown ({omitted} omitted)",
408 shown.len()
409 ));
410 } else {
411 lines.push(format!("uncommitted: {total} paths"));
412 }
413 lines.extend(shown);
414 }
415 if !git.diff.is_empty() {
416 lines.push("diff:".to_owned());
417 lines.extend(git.diff.iter().cloned());
418 }
419 HandoffSection { label: "files".to_owned(), lines }
420}
421
422fn board_section(slot: &SessionSlot, board: &[Task]) -> HandoffSection {
423 let mut lines = Vec::new();
424 let mut refs: Vec<String> = Vec::new();
425 for task in board.iter().filter(|task| task.owner.as_ref() == Some(slot)) {
426 lines.push(format!("{} ({})", task.subject, status_name(task.status)));
427 for link in &task.links {
428 let entry = format!("{}: {}", link_kind(link.kind), link.target);
429 if !refs.contains(&entry) {
430 refs.push(entry);
431 }
432 }
433 }
434 if lines.is_empty() {
435 lines.push("no rows owned".to_owned());
436 }
437 if !refs.is_empty() {
438 lines.push("links:".to_owned());
439 lines.extend(tail(&refs, FILE_CAP));
440 }
441 HandoffSection { label: "board".to_owned(), lines }
442}
443
444pub(crate) fn live_work_sections(
451 domain: Option<&crate::domain_session::DomainSession>,
452) -> Vec<HandoffSection> {
453 let Some(domain) = domain else {
456 return ["agents", "background", "monitors", "processes"]
457 .into_iter()
458 .map(|label| fact(label, "not read".to_owned()))
459 .collect();
460 };
461 let agents: Vec<String> = domain
462 .card_tracker
463 .cards()
464 .into_iter()
465 .filter(|card| card.running)
466 .map(|card| {
467 let kind = card.agent_type.unwrap_or_else(|| "agent".to_owned());
468 let backgrounded = if card.backgrounded { ", backgrounded" } else { "" };
469 format!("{kind}: {} ({} calls{backgrounded})", card.name, card.calls)
470 })
471 .collect();
472 let tasks: Vec<String> = domain
473 .background_tasks
474 .iter()
475 .map(|task| format!("{}: {} [{}]", task.task_type, task.description, task.task_id))
476 .collect();
477 let watching: Vec<String> = domain
478 .monitors
479 .iter()
480 .filter(|monitor| !monitor.status.is_terminal())
481 .map(|monitor| {
482 format!("{} [{}]: {}", monitor.description, monitor.tool_use_id, monitor.command)
483 })
484 .collect();
485 let ps: Vec<String> = match &domain.process_snapshot {
486 Some(snapshot) => snapshot
487 .processes
488 .iter()
489 .take(FILE_CAP)
490 .map(|process| format!("{} {}: {}", process.pid, process.name, process.command))
491 .collect(),
492 None => vec!["not scanned".to_owned()],
493 };
494 let section = |label: &str, lines: Vec<String>| HandoffSection {
495 label: label.to_owned(),
496 lines: if lines.is_empty() { vec!["none running".to_owned()] } else { lines },
497 };
498 vec![
499 section("agents", agents),
500 section("background", tasks),
501 section("monitors", watching),
502 section("processes", ps),
503 ]
504}
505
506fn record_section(seat: &HandoffSeat<'_>) -> HandoffSection {
507 HandoffSection {
508 label: "record".to_owned(),
509 lines: vec![
510 seat.transcript_path.display().to_string(),
511 format!("session {}", seat.from),
512 "anything this brief does not carry is in that transcript, not guessed".to_owned(),
513 ],
514 }
515}
516
517fn status_name(status: TaskStatus) -> &'static str {
518 match status {
519 TaskStatus::Pending => "pending",
520 TaskStatus::InProgress => "in progress",
521 TaskStatus::Waiting => "waiting",
522 TaskStatus::Completed => "completed",
523 TaskStatus::Failed => "failed",
524 TaskStatus::Canceled => "canceled",
525 }
526}
527
528fn link_kind(kind: forge_primitives::tasks::LinkKind) -> &'static str {
529 use forge_primitives::tasks::LinkKind;
530 match kind {
531 LinkKind::Spec => "spec",
532 LinkKind::Plan => "plan",
533 LinkKind::Issue => "issue",
534 LinkKind::Pr => "pr",
535 LinkKind::Branch => "branch",
536 LinkKind::Path => "path",
537 LinkKind::Other => "link",
538 }
539}
540
541fn cycle(messages: &[Message], turns: usize) -> HandoffCycle {
545 let mut requests = 0;
546 let mut prompt_tokens = 0;
547 for message in messages {
548 let Message::Assistant { message: envelope, .. } = message else { continue };
549 let Some(usage) = &envelope.usage else { continue };
550 requests += 1;
551 prompt_tokens +=
552 usage.input_tokens + usage.cache_creation_input_tokens + usage.cache_read_input_tokens;
553 }
554 HandoffCycle { requests, turns: u32::try_from(turns).unwrap_or(u32::MAX), prompt_tokens }
555}
556
557fn user_prompt_count(messages: &[Message]) -> usize {
561 messages
562 .iter()
563 .filter(|message| {
564 let Message::User { message: envelope, synthetic, .. } = message else {
565 return false;
566 };
567 !*synthetic && !is_machine_prompt(message) && text_of(&envelope.content).is_some()
568 })
569 .count()
570}
571
572fn is_machine_prompt(message: &Message) -> bool {
579 let Message::User { uuid, message: envelope, .. } = message else { return false };
580 if uuid.as_deref().is_some_and(|id| id.starts_with("forge_nudge_")) {
581 return true;
582 }
583 let Some(text) = text_of(&envelope.content) else { return false };
584 let text = text.trim_start();
585 text.starts_with("HANDOFF - ")
586 || text.starts_with(MACHINE_PREFIX)
587 || crate::conversation_turns::is_task_notice(text)
588 || crate::conversation_turns::is_interrupt_marker(text)
589 || text.starts_with("[Your previous response had no visible output.")
592}
593
594fn tool_uses(messages: &[Message]) -> impl Iterator<Item = (&str, &str, &serde_json::Value)> {
596 messages.iter().flat_map(|message| {
597 let Message::Assistant { message: envelope, parent_tool_use_id, .. } = message else {
598 return Vec::new().into_iter();
599 };
600 if forge_primitives::messages::names_a_dispatch(parent_tool_use_id.as_deref()) {
603 return Vec::new().into_iter();
604 }
605 envelope
606 .content
607 .iter()
608 .filter_map(|block| match block {
609 ContentBlock::ToolUse { id, name, input, .. } => {
610 Some((id.as_str(), name.as_str(), input))
611 }
612 _ => None,
613 })
614 .collect::<Vec<_>>()
615 .into_iter()
616 })
617}
618
619pub(crate) fn assistant_text(blocks: &[ContentBlock]) -> Option<String> {
621 text_of(blocks)
622}
623
624fn text_of(blocks: &[ContentBlock]) -> Option<String> {
626 let parts: Vec<&str> = blocks
627 .iter()
628 .filter_map(|block| match block {
629 ContentBlock::Text { text, .. } => Some(text.as_str()),
630 _ => None,
631 })
632 .collect();
633 let text = parts.join("\n");
634 (!text.trim().is_empty()).then_some(text)
635}
636
637fn list_line(label: &str, items: &[String], cap: usize) -> String {
644 let (shown, omitted) = first_and_newest(items, cap);
645 if omitted == 0 {
646 return format!("{label}: {}", items.join(", "));
647 }
648 format!(
649 "{label}: {} shown of {} ({omitted} omitted), first and newest: {}",
650 shown.len(),
651 items.len(),
652 shown.iter().map(|item| item.as_str()).collect::<Vec<_>>().join(", "),
653 )
654}
655
656fn first_and_newest<T>(items: &[T], cap: usize) -> (Vec<&T>, usize) {
658 if items.len() <= cap {
659 return (items.iter().collect(), 0);
660 }
661 let newest = cap.saturating_sub(1);
662 let mut shown: Vec<&T> = Vec::with_capacity(cap);
663 shown.push(&items[0]);
664 shown.extend(items[items.len() - newest..].iter());
665 let omitted = items.len() - shown.len();
666 (shown, omitted)
667}
668
669fn tail<T: Clone>(items: &[T], cap: usize) -> Vec<T> {
671 items[items.len().saturating_sub(cap)..].to_vec()
672}
673
674pub(crate) fn parse_judgment(raw: &str) -> Vec<HandoffSection> {
681 let mut sections: Vec<HandoffSection> = Vec::new();
682 for line in raw.lines() {
683 let trimmed = line.trim();
684 if let Some((heading, rest)) = judgment_heading(trimmed) {
685 sections.push(HandoffSection { label: heading, lines: vec![rest] });
686 continue;
687 }
688 match sections.last_mut() {
689 Some(section) => section.lines.push(trimmed.to_owned()),
690 None if !trimmed.is_empty() => {
691 sections.push(HandoffSection {
692 label: "note".to_owned(),
693 lines: vec![trimmed.to_owned()],
694 });
695 }
696 None => {}
697 }
698 }
699 for section in &mut sections {
700 section.lines.retain(|line| !line.is_empty());
701 }
702 cap_judgment(sections)
703}
704
705pub(crate) fn parses_as_note(raw: &str) -> bool {
709 !parse_judgment(raw).is_empty()
710}
711
712pub(crate) fn missing_note() -> Vec<HandoffSection> {
716 vec![HandoffSection {
717 label: "note".to_owned(),
718 lines: vec![
719 "the handoff note did not arrive; this session's transcript is named in the record \
720 above, and anything it left is there"
721 .to_owned(),
722 ],
723 }]
724}
725
726fn judgment_heading(line: &str) -> Option<(String, String)> {
735 let trimmed = line.trim();
736 let unbulleted = trimmed.trim_start_matches(['#', '*', '-', ' ']);
737 let ordered = ordered_marker(unbulleted);
740 let marked = trimmed.starts_with('#')
741 || trimmed.starts_with("**")
742 || trimmed.trim_start_matches(['-', ' ']).starts_with("**")
743 || ordered.is_some_and(|rest| rest.starts_with("**"));
744 let text = match ordered {
745 Some(rest) => rest.trim_start_matches(['#', '*', '-', ' ']),
746 None => unbulleted,
747 };
748 if text.is_empty() {
749 return None;
750 }
751 let emphasis = text.split_once("**").filter(|(_, rest)| !rest.trim().is_empty());
752 let colon = text.split_once(':');
753 let (label, rest) = match (marked, emphasis) {
754 (true, Some((label, rest))) => match colon {
758 Some((colon_label, colon_rest)) if colon_label.len() < label.len() => {
759 (colon_label, Some(colon_rest))
760 }
761 _ => (label, Some(rest)),
762 },
763 _ => match colon {
764 Some((label, rest)) => (label, Some(rest)),
765 None if marked => (text, None),
766 None => return None,
767 },
768 };
769 let label =
772 label.trim_end_matches("**").trim().trim_end_matches(':').trim_end_matches('.').trim();
773 if !heading_label(label, marked) {
774 return None;
775 }
776 let body = rest.map_or_else(String::new, |rest| {
777 let rest = strip_emphasis(rest);
782 rest.strip_prefix(':').unwrap_or(rest).trim().to_owned()
783 });
784 Some((label.to_owned(), body))
785}
786
787fn heading_label(label: &str, marked: bool) -> bool {
791 let chars = label.chars().count();
792 if chars == 0 || chars > if marked { 60 } else { 40 } {
793 return false;
794 }
795 if label.contains(['.', '!', '?', ',']) {
796 return false;
797 }
798 marked
799 || label.chars().next().is_some_and(char::is_uppercase)
800 && label.chars().all(|c| c.is_alphanumeric() || c == ' ' || c == '\'' || c == '-')
801}
802
803fn ordered_marker(text: &str) -> Option<&str> {
808 let digits = text.bytes().take_while(u8::is_ascii_digit).count();
809 if digits == 0 {
810 return None;
811 }
812 let rest = text[digits..].strip_prefix('.').or_else(|| text[digits..].strip_prefix(')'))?;
813 rest.starts_with(' ').then(|| rest.trim_start())
814}
815
816fn strip_emphasis(text: &str) -> &str {
818 text.strip_prefix("**").unwrap_or(text)
819}
820
821fn cap_judgment(mut sections: Vec<HandoffSection>) -> Vec<HandoffSection> {
824 let mut budget = JUDGMENT_CAP;
825 let mut kept: Vec<HandoffSection> = Vec::new();
826 let mut cut = false;
827 for section in sections.drain(..) {
828 let mut lines = Vec::new();
829 for line in section.lines {
830 let cost = line.chars().count() + 1;
831 if cost > budget {
832 cut = true;
833 break;
834 }
835 budget -= cost;
836 lines.push(line);
837 }
838 if !lines.is_empty() {
839 kept.push(HandoffSection { label: section.label, lines });
840 }
841 if cut {
842 break;
843 }
844 }
845 if cut {
846 match kept.last_mut() {
849 Some(section) => section
850 .lines
851 .push("[the rest of this reply is in the transcript named above]".to_owned()),
852 None => kept.push(HandoffSection {
853 label: "note".to_owned(),
854 lines: vec![
855 "[the reply was longer than the brief allows; it is in the transcript \
856 named above]"
857 .to_owned(),
858 ],
859 }),
860 }
861 }
862 kept
863}
864
865pub(crate) fn brief_text(pair: &str, brief: &forge_primitives::HandoffBrief) -> String {
872 let mut out = format!("HANDOFF - {pair}\n");
873 let sourced = brief.judgment.iter().any(|section| section.label == "source");
877 out.push_str(if sourced {
878 "\nPART 1 is forge's own ground truth and is authoritative for facts; PART 2 is the \
879 handing-over session's own words, apart from the source row forge added.\n"
880 } else {
881 "\nPART 1 is forge's own ground truth and is authoritative for facts; PART 2 is the \
882 handing-over session's own words.\n"
883 });
884 out.push_str("\nPART 1: GROUND TRUTH (written by forge, not by a model)\n");
885 render_sections(&mut out, &brief.ground_truth);
886 if sourced {
887 out.push_str(
888 "\nPART 2: JUDGMENT (the source row is forge's; the rest is the session's own \
889 words)\n",
890 );
891 } else {
892 out.push_str("\nPART 2: JUDGMENT (written by the session that is handing over)\n");
893 }
894 render_sections(&mut out, &brief.judgment);
895 out
896}
897
898fn render_sections(out: &mut String, sections: &[HandoffSection]) {
899 use std::fmt::Write as _;
900 for section in sections {
901 for (at, line) in section.lines.iter().enumerate() {
902 let _ = if at == 0 {
903 writeln!(out, "{}: {line}", section.label)
904 } else {
905 writeln!(out, " {line}")
906 };
907 }
908 if section.lines.is_empty() {
909 let _ = writeln!(out, "{}:", section.label);
910 }
911 }
912}
913
914const MACHINE_PREFIX: &str = "A handoff is starting for this session:";
919
920fn judgment_opening() -> String {
922 format!(
923 "{MACHINE_PREFIX} forge will replace it with a new session that continues the work. \
924 This turn has two jobs, in this order, and it ends on the text that becomes the \
925 judgment half of the brief the new session is given."
926 )
927}
928
929const JUDGMENT_BODY: &str = "\
931Invoke the handoff skill and follow it. It completes this session's learnings \
932into memory, records and stops what is still running so the new session can \
933resume it, then calls `agents__handoff` with the note. If this environment has \
934no such skill, do those three steps from this prompt: memory first, then close \
935out what is running, then call `agents__handoff` with the note.";
936
937const JUDGMENT_CONTRACT: &str = "\
943Write the continuation the next session needs, in whatever shape reads best - \
944there is no set of headings to fill in. It must carry: where things stand, and \
945what is done and verified; what is in flight, with the row, PR, branch or \
946session id that names it; what you tried that failed, and what stopped it; and \
947the exact next action, with the file, command or row it starts at. Point at \
948what you recorded instead of retelling it: the memory files by name, the rolling \
949handoff document, the board rows by id, the PR numbers, the transcript. Never \
950write a key, token, password or personal data into the note. This note is Step \
9512's handoff document; pass the same text to `agents__handoff` as `brief`. Keep \
952it under 400 words.";
953
954pub(crate) fn judgment_prompt() -> String {
957 format!("{}\n\n{JUDGMENT_BODY}\n\n{JUDGMENT_CONTRACT}", judgment_opening())
958}
959
960const SKILL: &str = include_str!("handoff/SKILL.md");
962
963const WIZARD_SKILL: &str = include_str!("wizard/SKILL.md");
965const WIZARD_TEMPLATE: &str = include_str!("wizard/template.sh");
966
967pub struct ShippedSkill {
971 pub name: &'static str,
972 pub files: &'static [(&'static str, &'static str)],
973 pub retired: &'static [&'static str],
978}
979
980const HANDOFF_FILES: &[(&str, &str)] = &[("SKILL.md", SKILL)];
981const WIZARD_FILES: &[(&str, &str)] =
982 &[("SKILL.md", WIZARD_SKILL), ("template.sh", WIZARD_TEMPLATE)];
983
984const SHIPPED_SKILLS: &[ShippedSkill] = &[
990 ShippedSkill { name: "handoff", files: HANDOFF_FILES, retired: &[] },
991 ShippedSkill { name: "wizard", files: WIZARD_FILES, retired: &[] },
992];
993
994pub fn shipped_skills() -> &'static [ShippedSkill] {
996 SHIPPED_SKILLS
997}
998
999#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1001pub enum SkillInstall {
1002 Created,
1003 Replaced,
1004 Swept,
1008 Unchanged,
1009}
1010
1011pub fn skill_dir(config_dir: &Path, name: &str) -> PathBuf {
1014 config_dir.join("skills").join(name)
1015}
1016
1017pub fn skill_path(config_dir: &Path, name: &str) -> PathBuf {
1020 skill_dir(config_dir, name).join("SKILL.md")
1021}
1022
1023pub fn install_skill(config_dir: &Path, skill: &ShippedSkill) -> std::io::Result<SkillInstall> {
1034 let dir = skill_dir(config_dir, skill.name);
1035 if let Ok(metadata) = std::fs::symlink_metadata(&dir)
1039 && metadata.file_type().is_symlink()
1040 {
1041 tracing::warn!(
1042 target: "forge_workspace::skills",
1043 event_name = "skill_dir_is_a_symlink",
1044 skill = skill.name,
1045 path = %dir.display(),
1046 "the skill directory is a symlink; nothing is installed through it",
1047 );
1048 return Err(std::io::Error::new(
1049 std::io::ErrorKind::InvalidInput,
1050 format!("{}: the skill directory is a symlink", dir.display()),
1051 ));
1052 }
1053 let mut held_a_copy = false;
1054 let mut differing: Vec<(&'static str, &'static str)> = Vec::new();
1055 for (name, contents) in skill.files {
1056 match std::fs::read(dir.join(name)) {
1057 Ok(existing) => {
1058 held_a_copy = true;
1059 if existing != contents.as_bytes() {
1060 differing.push((name, contents));
1061 }
1062 }
1063 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1064 differing.push((name, contents));
1065 }
1066 Err(error) => return Err(named(&error, &dir.join(name))),
1067 }
1068 }
1069
1070 let sweep = sweep_candidates(&dir, skill);
1071 let outcome = if !held_a_copy {
1072 SkillInstall::Created
1073 } else if differing.is_empty() && sweep.is_empty() {
1074 return Ok(SkillInstall::Unchanged);
1075 } else if differing.is_empty() {
1076 SkillInstall::Swept
1079 } else {
1080 SkillInstall::Replaced
1081 };
1082
1083 std::fs::create_dir_all(&dir)?;
1084 let mut staged: Vec<(PathBuf, PathBuf)> = Vec::with_capacity(differing.len());
1088 for (name, contents) in &differing {
1089 let path = dir.join(name);
1090 let temp = dir.join(format!("{name}.tmp"));
1091 if let Err(error) = std::fs::write(&temp, contents.as_bytes()) {
1092 let _ = std::fs::remove_file(&temp);
1093 remove_all(&staged);
1094 return Err(named(&error, &path));
1095 }
1096 staged.push((temp, path));
1097 }
1098 for (temp, path) in &staged {
1099 if let Err(error) = std::fs::rename(temp, path) {
1102 remove_all(&staged);
1103 return Err(named(&error, path));
1104 }
1105 }
1106
1107 let mut removed = 0_usize;
1111 for path in &sweep {
1112 match std::fs::remove_file(path) {
1113 Ok(()) => {
1114 removed += 1;
1115 tracing::debug!(
1116 target: "forge_workspace::skills",
1117 event_name = "skill_file_removed",
1118 skill = skill.name,
1119 path = %path.display(),
1120 "a skill file was removed",
1121 );
1122 }
1123 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1124 Err(error) => tracing::warn!(
1125 target: "forge_workspace::skills",
1126 event_name = "skill_file_remove_failed",
1127 skill = skill.name,
1128 path = %path.display(),
1129 %error,
1130 "a skill file could not be removed; the install itself is unaffected",
1131 ),
1132 }
1133 }
1134 if removed > 0 {
1135 tracing::info!(
1136 target: "forge_workspace::skills",
1137 event_name = "skill_files_swept",
1138 skill = skill.name,
1139 removed,
1140 "skill files were removed",
1141 );
1142 }
1143 Ok(outcome)
1144}
1145
1146fn sweep_candidates(dir: &Path, skill: &ShippedSkill) -> Vec<PathBuf> {
1156 let mut sweep = Vec::new();
1157 let mut push = |path: PathBuf| {
1158 if std::fs::symlink_metadata(&path).is_ok_and(|metadata| metadata.file_type().is_file()) {
1159 sweep.push(path);
1160 }
1161 };
1162 for (name, _) in skill.files {
1163 push(dir.join(format!("{name}.tmp")));
1164 }
1165 for retired in skill.retired {
1166 if !skill.files.iter().any(|(name, _)| name == retired) {
1167 push(dir.join(retired));
1168 }
1169 }
1170 sweep
1171}
1172
1173fn remove_all(staged: &[(PathBuf, PathBuf)]) {
1174 for (temp, _) in staged {
1175 let _ = std::fs::remove_file(temp);
1176 }
1177}
1178
1179fn named(error: &std::io::Error, path: &Path) -> std::io::Error {
1180 std::io::Error::new(error.kind(), format!("{}: {error}", path.display()))
1181}
1182
1183#[derive(Debug, Clone)]
1185pub(crate) struct HeldPrompt {
1186 pub text: String,
1187 pub attachments: Vec<forge_primitives::ImageAttachment>,
1188 pub source: crate::protocol::PromptSource,
1189 pub uuid: String,
1194 pub sender: Option<SessionSlot>,
1198}
1199
1200#[derive(Debug, Clone)]
1202pub(crate) struct SuccessorWatch {
1203 pub to: String,
1205 pub turns: u32,
1207 pub predecessor_reads: Vec<String>,
1209 pub measure_at_boundary: bool,
1212}
1213
1214#[derive(Debug, Clone)]
1217pub(crate) struct PendingBrief {
1218 pub to: String,
1221 pub text: String,
1222}
1223
1224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1226pub struct FillReading {
1227 pub percent: u8,
1229 pub total_tokens: u64,
1230 pub max_tokens: u64,
1231}
1232
1233impl FillReading {
1234 pub fn fires(&self, at_percent: u8) -> bool {
1240 if self.max_tokens == 0 {
1241 return self.percent >= at_percent;
1243 }
1244 self.total_tokens.saturating_mul(100)
1245 >= u64::from(at_percent).saturating_mul(self.max_tokens)
1246 }
1247}
1248
1249pub(crate) struct SwapRequest {
1251 pub slot: SessionSlot,
1252 pub from: String,
1253 pub to: String,
1254 pub reason: HandoffReason,
1255 pub fill: Option<FillReading>,
1256 pub at_percent: u8,
1257 pub role: Role,
1258 pub judgment: Vec<HandoffSection>,
1259 pub cwd: String,
1260 pub launch_settings: SessionLaunchSettings,
1261 pub token: Option<u64>,
1264}
1265
1266impl crate::Workspace {
1267 pub(crate) fn set_pending_brief(&self, slot: &SessionSlot, brief: PendingBrief) {
1269 self.pending_handoffs.lock().insert(slot.clone(), brief);
1270 }
1271
1272 pub(crate) fn take_pending_brief(&self, slot: &SessionSlot) -> Option<PendingBrief> {
1274 self.pending_handoffs.lock().remove(slot)
1275 }
1276
1277 pub(crate) fn clear_pending_brief(&self, slot: &SessionSlot) {
1279 self.pending_handoffs.lock().remove(slot);
1280 }
1281
1282 pub(crate) fn begin_swap(&self, slot: &SessionSlot) -> u64 {
1286 let token = self.swap_epochs.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
1287 self.swapping.lock().insert(slot.clone(), token);
1288 token
1289 }
1290
1291 pub(crate) fn swap_in_progress(&self, slot: &SessionSlot) -> bool {
1293 self.swapping.lock().contains_key(slot)
1294 }
1295
1296 pub fn handoff_in_flight(&self, slot: &SessionSlot) -> bool {
1303 self.swap_in_progress(slot)
1304 || self.domain_session_for(slot).is_some_and(|domain| domain.lock().handoff.awaiting)
1305 }
1306
1307 fn end_machine_turn(&self, slot: &SessionSlot) {
1314 let _ =
1315 self.update_tx.send(SessionUpdate::MachineTurn { key: slot.clone(), machine: None });
1316 }
1317
1318 pub(crate) fn swap_is_current(&self, slot: &SessionSlot, token: u64) -> bool {
1325 self.swapping.lock().get(slot) == Some(&token)
1326 }
1327
1328 pub(crate) fn dispatch_peer_prompt_under(
1332 self: &Arc<Self>,
1333 caller: &SessionSlot,
1334 key: &SessionSlot,
1335 text: String,
1336 uuid: String,
1337 ) -> Result<(), crate::DispatchError> {
1338 let held = HeldPrompt {
1339 text: text.clone(),
1340 attachments: Vec::new(),
1341 source: crate::protocol::PromptSource::Peer,
1342 uuid: uuid.clone(),
1343 sender: Some(caller.clone()),
1344 };
1345 if self.hold_prompt(key, held) {
1346 return Ok(());
1347 }
1348 self.route(Command::PromptUnder {
1349 key: key.clone(),
1350 text,
1351 attachments: Vec::new(),
1352 uuid,
1353 source: crate::protocol::PromptSource::Peer,
1354 dispatch_id: None,
1355 })
1356 }
1357
1358 pub(crate) fn hold_prompt(&self, slot: &SessionSlot, prompt: HeldPrompt) -> bool {
1360 if !self.swap_in_progress(slot) {
1361 return false;
1362 }
1363 self.held_prompts.lock().entry(slot.clone()).or_default().push(prompt);
1364 true
1365 }
1366
1367 pub(crate) fn end_swap(&self, slot: &SessionSlot) -> Vec<HeldPrompt> {
1369 self.swapping.lock().remove(slot);
1370 self.held_prompts.lock().remove(slot).unwrap_or_default()
1371 }
1372
1373 pub(crate) fn record_handoff(&self, record: &HandoffRecord, predecessor_reads: &[String]) {
1377 let guard = self.db.lock();
1378 let Some(db) = guard.as_ref() else { return };
1379 if let Err(error) = crate::store::handoffs::put(db, record, predecessor_reads) {
1380 tracing::warn!(
1381 target: "forge_workspace::handoff",
1382 event_name = "handoff_record_failed",
1383 slot = %record.slot.display(),
1384 %error,
1385 "the handoff record could not be written; the swap itself is unaffected",
1386 );
1387 }
1388 }
1389
1390 pub(crate) fn pending_brief_for(&self, slot: &SessionSlot) -> Option<PendingBrief> {
1392 self.pending_handoffs.lock().get(slot).cloned()
1393 }
1394
1395 pub(crate) fn handoff_for_successor(
1397 &self,
1398 slot: &SessionSlot,
1399 occupant: &str,
1400 ) -> Option<(HandoffRecord, Vec<String>)> {
1401 let guard = self.db.lock();
1402 let db = guard.as_ref()?;
1403 match crate::store::handoffs::for_successor(db, slot, occupant) {
1404 Ok(found) => found,
1405 Err(error) => {
1406 tracing::warn!(
1407 target: "forge_workspace::handoff",
1408 event_name = "handoff_record_unreadable",
1409 slot = %slot.display(),
1410 %error,
1411 "the handoff record could not be read; this seat's first turns go unmeasured",
1412 );
1413 None
1414 }
1415 }
1416 }
1417
1418 pub(crate) fn measure_successor(
1422 self: &Arc<Self>,
1423 slot: SessionSlot,
1424 occupant: String,
1425 cwd: String,
1426 predecessor_reads: Vec<String>,
1427 ) {
1428 let workspace = Arc::clone(self);
1429 tokio::spawn(async move {
1430 let config_dir = workspace.config_dir().to_path_buf();
1431 let read_id = occupant.clone();
1432 let read = tokio::task::spawn_blocking(move || {
1435 forge_agent::session_history_main_thread(&config_dir, &read_id, &cwd)
1436 })
1437 .await;
1438 let messages = read.map(|history| history.messages).unwrap_or_default();
1439 let Some(counts) = successor_measurement(&messages, &predecessor_reads) else {
1440 tracing::debug!(
1441 target: "forge_workspace::handoff",
1442 event_name = "handoff_successor_measurement_skipped",
1443 slot = %slot.display(),
1444 "the successor's transcript read answered nothing; no counts are written",
1445 );
1446 return;
1447 };
1448 let guard = workspace.db.lock();
1449 let Some(db) = guard.as_ref() else { return };
1450 if let Err(error) = crate::store::handoffs::set_successor(db, &slot, &occupant, counts)
1451 {
1452 tracing::warn!(
1453 target: "forge_workspace::handoff",
1454 event_name = "handoff_successor_measurement_failed",
1455 slot = %slot.display(),
1456 %error,
1457 "the successor's first turns could not be written to the record",
1458 );
1459 }
1460 });
1461 }
1462
1463 pub(crate) fn start_handoff(self: &Arc<Self>, request: SwapRequest) {
1470 let workspace = Arc::clone(self);
1471 tokio::spawn(async move {
1472 workspace.run_handoff(request).await;
1473 });
1474 }
1475
1476 async fn run_handoff(self: Arc<Self>, request: SwapRequest) {
1477 let Some(token) = request.token else {
1482 tracing::debug!(
1483 target: "forge_workspace::handoff",
1484 event_name = "handoff_build_without_token",
1485 slot = %request.slot.display(),
1486 "a handoff build had no swap token; it is not run",
1487 );
1488 self.end_machine_turn(&request.slot);
1489 return;
1490 };
1491 if !self.swap_is_current(&request.slot, token) {
1492 tracing::debug!(
1493 target: "forge_workspace::handoff",
1494 event_name = "handoff_build_lost_the_seat",
1495 slot = %request.slot.display(),
1496 "the swap gate closed before the build began; the handoff is not run",
1497 );
1498 self.end_machine_turn(&request.slot);
1499 return;
1500 }
1501 let cwd = PathBuf::from(&request.cwd);
1502 let transcript_path = transcript_path(self.config_dir(), &cwd, &request.from);
1503 let board: Vec<Task> = self
1504 .board_rows(request.slot.project(), SystemTime::now(), crate::board::DEFAULT_STALE_SECS)
1505 .into_iter()
1506 .map(|row| row.task)
1507 .collect();
1508 let seat = HandoffSeat {
1509 slot: &request.slot,
1510 from: &request.from,
1511 cwd: &cwd,
1512 transcript_path: &transcript_path,
1513 role: request.role.clone(),
1514 };
1515 let live = {
1516 let domain = self.domain_session_for(&request.slot);
1517 match domain {
1518 Some(domain) => live_work_sections(Some(&domain.lock())),
1519 None => live_work_sections(None),
1520 }
1521 };
1522 let truth = ground_truth(self.config_dir(), &seat, &board, live).await;
1523 let GroundTruth { sections, cycle, reads } = truth;
1524 let record = HandoffRecord {
1525 slot: request.slot.clone(),
1526 from: request.from.clone(),
1527 to: request.to.clone(),
1528 reason: request.reason,
1529 fill_percent: request.fill.map(|fill| fill.percent),
1530 total_tokens: request.fill.map(|fill| fill.total_tokens),
1531 max_tokens: request.fill.map(|fill| fill.max_tokens),
1532 at_percent: request.at_percent,
1533 brief: HandoffBrief { ground_truth: sections, judgment: request.judgment },
1534 transcript_path: transcript_path.display().to_string(),
1535 at: crate::install::rfc3339_now(),
1536 cycle,
1537 successor: None,
1538 };
1539 let text = brief_text(&record.pair(), &record.brief);
1540 if !self.swap_is_current(&request.slot, token) {
1545 tracing::debug!(
1546 target: "forge_workspace::handoff",
1547 event_name = "handoff_build_lost_the_seat",
1548 slot = %request.slot.display(),
1549 "the swap gate closed while the brief was built; the handoff is not run",
1550 );
1551 self.end_machine_turn(&request.slot);
1552 return;
1553 }
1554 self.record_handoff(&record, &reads);
1555 self.set_pending_brief(&request.slot, PendingBrief { to: request.to.clone(), text });
1558 let _ = self
1559 .update_tx
1560 .send(SessionUpdate::Handoff { key: request.slot.clone(), handoff: record });
1561 self.replace_browser_seat(&request.slot);
1565 if let Err(error) = self.dispatch(Command::NewSession {
1566 key: request.slot.clone(),
1567 cwd: request.cwd,
1568 launch_settings: request.launch_settings,
1569 session_id: Some(request.to),
1570 }) {
1571 self.clear_pending_brief(&request.slot);
1572 let held = self.end_swap(&request.slot);
1573 if !held.is_empty() {
1574 tracing::warn!(
1575 target: "forge_workspace::handoff",
1576 event_name = "handoff_prompts_dropped",
1577 slot = %request.slot.display(),
1578 held = held.len(),
1579 "the handoff could not start its successor; prompts held for it were dropped",
1580 );
1581 }
1582 for prompt in &held {
1583 let _ = self.update_tx.send(SessionUpdate::PromptLifecycle {
1585 key: request.slot.clone(),
1586 uuid: prompt.uuid.clone(),
1587 state: "cancelled".to_owned(),
1588 });
1589 if let Some(sender) = &prompt.sender {
1590 self.notice_undelivered_message(
1591 sender,
1592 &request.slot,
1593 crate::mcp::peers::types::PeerFailureReason::TargetConnectionFailed,
1594 );
1595 } else {
1596 let first = prompt.text.lines().next().unwrap_or_default();
1597 let _ = self.update_tx.send(SessionUpdate::Notice {
1598 key: request.slot.clone(),
1599 severity: NoticeSeverity::Error,
1600 text: format!("a prompt held for the failed handoff did not run: {first}"),
1601 });
1602 }
1603 }
1604 let _ = self.update_tx.send(SessionUpdate::Notice {
1605 key: request.slot.clone(),
1606 severity: NoticeSeverity::Error,
1607 text: format!("the handoff could not start a new session: {error}"),
1608 });
1609 self.end_machine_turn(&request.slot);
1610 }
1611 }
1612}
1613
1614pub(crate) fn mint_session_id() -> String {
1621 uuid::Uuid::new_v4().to_string()
1622}
1623
1624pub(crate) fn transcript_path(config_dir: &Path, cwd: &Path, session_id: &str) -> PathBuf {
1627 let cwd = cwd.to_string_lossy();
1628 let key = forge_agent::userdata::catalog::scan::project_key_for_directory(
1629 (!cwd.is_empty()).then_some(cwd.as_ref()),
1630 );
1631 forge_sdk::projects_dir_for(config_dir).join(key).join(format!("{session_id}.jsonl"))
1632}
1633
1634#[cfg(test)]
1635mod tests {
1636 use super::*;
1637 use forge_primitives::messages::{AssistantEnvelope, Extras, Usage, UserEnvelope};
1638 use forge_primitives::tasks::{LinkKind, TaskLink};
1639
1640 fn user(text: &str) -> Message {
1641 Message::User {
1642 message: UserEnvelope {
1643 role: "user".to_owned(),
1644 content: vec![ContentBlock::Text { text: text.to_owned(), extras: Extras::new() }],
1645 extras: Extras::new(),
1646 },
1647 session_id: "s".to_owned(),
1648 parent_tool_use_id: None,
1649 uuid: None,
1650 tool_use_result: None,
1651 timestamp: None,
1652 synthetic: false,
1653 extras: Extras::new(),
1654 }
1655 }
1656
1657 fn assistant(content: Vec<ContentBlock>, usage: Option<Usage>) -> Message {
1658 Message::Assistant {
1659 message: AssistantEnvelope {
1660 id: "m".to_owned(),
1661 role: "assistant".to_owned(),
1662 model: "a-model".to_owned(),
1663 content,
1664 stop_reason: None,
1665 stop_sequence: None,
1666 usage,
1667 extras: Extras::new(),
1668 },
1669 session_id: "s".to_owned(),
1670 parent_tool_use_id: None,
1671 error: None,
1672 uuid: None,
1673 timestamp: None,
1674 extras: Extras::new(),
1675 }
1676 }
1677
1678 fn text(value: &str) -> ContentBlock {
1679 ContentBlock::Text { text: value.to_owned(), extras: Extras::new() }
1680 }
1681
1682 fn call(id: &str, name: &str, input: serde_json::Value) -> Message {
1683 assistant(
1684 vec![ContentBlock::ToolUse {
1685 id: id.to_owned(),
1686 name: name.to_owned(),
1687 input,
1688 extras: Extras::new(),
1689 }],
1690 None,
1691 )
1692 }
1693
1694 fn result(id: &str, output: &str, is_error: bool) -> Message {
1695 Message::User {
1696 message: UserEnvelope {
1697 role: "user".to_owned(),
1698 content: vec![ContentBlock::ToolResult {
1699 tool_use_id: id.to_owned(),
1700 content: serde_json::Value::String(output.to_owned()),
1701 is_error,
1702 extras: Extras::new(),
1703 }],
1704 extras: Extras::new(),
1705 },
1706 session_id: "s".to_owned(),
1707 parent_tool_use_id: None,
1708 uuid: None,
1709 tool_use_result: None,
1710 timestamp: None,
1711 synthetic: false,
1712 extras: Extras::new(),
1713 }
1714 }
1715
1716 fn usage(input: u64, cache_read: u64) -> Usage {
1717 Usage {
1718 input_tokens: input,
1719 output_tokens: 10,
1720 cache_creation_input_tokens: 0,
1721 cache_read_input_tokens: cache_read,
1722 extras: Extras::new(),
1723 }
1724 }
1725
1726 fn task(subject: &str, owner: Option<SessionSlot>, link: Option<&str>) -> Task {
1727 Task {
1728 id: forge_primitives::TaskId::from(subject),
1729 project_name: "forge".to_owned(),
1730 subject: subject.to_owned(),
1731 active_form: None,
1732 detail: None,
1733 status: TaskStatus::InProgress,
1734 owner,
1735 parent: None,
1736 waiting_on: None,
1737 estimate: None,
1738 rank: None,
1739 verify: None,
1740 attested: None,
1741 reserved: None,
1742 links: link
1743 .map(|target| TaskLink {
1744 kind: LinkKind::Pr,
1745 label: None,
1746 target: target.to_owned(),
1747 state: None,
1748 added_at: SystemTime::UNIX_EPOCH,
1749 })
1750 .into_iter()
1751 .collect(),
1752 attempt: 0,
1753 archived_at: None,
1754 created_at: SystemTime::UNIX_EPOCH,
1755 updated_at: SystemTime::UNIX_EPOCH,
1756 }
1757 }
1758
1759 fn seat() -> HandoffSeat<'static> {
1760 HandoffSeat {
1761 slot: Box::leak(Box::new(SessionSlot::worker("Busytools", "forge", "handoff-cut"))),
1762 from: "9cfe315c-aaaa-bbbb-cccc-dddddddddddd",
1763 cwd: Path::new("/tmp/forge"),
1764 transcript_path: Path::new("/tmp/projects/forge/9cfe315c.jsonl"),
1765 role: Role::Worker { charter_opening: Some("You build the cut.".to_owned()) },
1766 }
1767 }
1768
1769 #[test]
1776 fn the_seat_and_the_record_name_the_identity_and_the_transcript() {
1777 let ground = assemble(&seat(), &[], &HandoffGitState::default(), &[], &[], Vec::new());
1778 let labels: Vec<&str> = ground.sections.iter().map(|s| s.label.as_str()).collect();
1779 assert_eq!(
1780 &labels[..3],
1781 ["seat", "cwd", "branch"],
1782 "the seat block is one fact per row: {labels:?}",
1783 );
1784 let values: Vec<Vec<&str>> = ground.sections[..3]
1785 .iter()
1786 .map(|section| section.lines.iter().map(String::as_str).collect())
1787 .collect();
1788 assert_eq!(
1789 values,
1790 vec![
1791 vec!["Busytools/forge/handoff-cut"],
1792 vec!["/tmp/forge"],
1793 vec!["not a git repository"],
1794 ],
1795 "each row carries its own value",
1796 );
1797 let git = HandoffGitState {
1798 branch: Some("worktree-handoff-cut".to_owned()),
1799 head: Some("94cab2f20".to_owned()),
1800 ..HandoffGitState::default()
1801 };
1802 let on_a_branch = assemble(&seat(), &[], &git, &[], &[], Vec::new());
1803 assert_eq!(
1804 on_a_branch.sections[2].label, "branch",
1805 "the branch keeps its own row: {:?}",
1806 on_a_branch.sections[2],
1807 );
1808 assert_eq!(
1809 on_a_branch.sections[2].lines,
1810 vec!["worktree-handoff-cut @ 94cab2f20".to_owned()],
1811 "and names the commit it is at",
1812 );
1813 let record = ground.sections.last().expect("the record");
1814 assert_eq!(record.label, "record");
1815 assert!(
1816 record.lines.contains(&"/tmp/projects/forge/9cfe315c.jsonl".to_owned()),
1817 "the transcript path: {:?}",
1818 record.lines,
1819 );
1820 assert!(
1821 record.lines.contains(&"session 9cfe315c-aaaa-bbbb-cccc-dddddddddddd".to_owned()),
1822 "and the session id: {:?}",
1823 record.lines,
1824 );
1825 let role = ground.sections.iter().find(|section| section.label == "role").expect("role");
1826 assert_eq!(
1827 role.lines,
1828 vec!["worker, charter opens: You build the cut.".to_owned()],
1829 "the role line says which charter this brief belongs to",
1830 );
1831 }
1832
1833 #[test]
1837 fn forge_prompts_and_prior_briefs_are_not_turns() {
1838 let forged = |text: &str| {
1839 let mut frame = user(text);
1840 if let Message::User { uuid, .. } = &mut frame {
1841 *uuid = Some("forge_nudge_7_deadbeef".to_owned());
1842 }
1843 frame
1844 };
1845 let messages = vec![
1846 user("ship the handoff"),
1847 forged("A handoff is starting for this session: its context is nearly full"),
1848 user("and open the PR"),
1849 user("HANDOFF - old -> new\n\nPART 1: GROUND TRUTH"),
1850 ];
1851 assert_eq!(user_prompt_count(&messages), 2, "only the two real prompts are turns");
1852 }
1853
1854 #[test]
1857 fn machine_rows_are_neither_prompts_nor_successor_turns() {
1858 let messages = vec![
1859 user("ship the handoff"),
1860 user("<task-notification>\n<task-id>b1</task-id>\n</task-notification>"),
1861 user("[Request interrupted by user for tool use]"),
1862 user("[Your previous response had no visible output. Please continue.]"),
1863 ];
1864 assert_eq!(user_prompt_count(&messages), 1, "only the person's own row is a turn");
1865 let counts = successor_counts(&messages, &[]);
1866 assert_eq!(counts.turns, 1, "and only that row is a successor turn: {counts:?}");
1867 }
1868
1869 #[test]
1871 fn a_peer_message_is_a_turn() {
1872 let messages = vec![
1873 user(
1874 "[Message id=m-1 from agent 'forge/steward' (org 'Busytools')]\n\nis the cron filed?",
1875 ),
1876 user("ship the handoff"),
1877 ];
1878 assert_eq!(user_prompt_count(&messages), 2, "the peer's row and the person's");
1879 }
1880
1881 #[test]
1883 fn the_uncommitted_count_is_the_trees_not_the_caps() {
1884 let git = HandoffGitState {
1885 branch: Some("main".to_owned()),
1886 head: Some("abc".to_owned()),
1887 dirty: (0..20).map(|n| format!(" M file{n}.rs")).collect(),
1888 dirty_total: 60,
1889 diff: Vec::new(),
1890 };
1891 let section = files_section(&[], &git, &[]);
1892 assert!(
1893 section
1894 .lines
1895 .iter()
1896 .any(|line| line.contains("uncommitted: 60 paths, newest 20 shown (40 omitted)")),
1897 "the line states the total and what it omits: {:?}",
1898 section.lines,
1899 );
1900 }
1901
1902 #[test]
1907 fn a_subagents_calls_stay_out_of_the_brief() {
1908 let mut child = call("t-child", "Read", serde_json::json!({ "file_path": "p/child.txt" }));
1909 if let Message::Assistant { parent_tool_use_id, .. } = &mut child {
1910 *parent_tool_use_id = Some("toolu_dispatch".to_owned());
1911 }
1912 let main = call("t-main", "Edit", serde_json::json!({ "file_path": "p/main.txt" }));
1913 let messages = vec![child, main];
1914
1915 let section = files_section(&messages, &HandoffGitState::default(), &[]);
1916 let body = section.lines.join("\n");
1917
1918 assert!(body.contains("p/main.txt"), "the session's own edit is named: {body}");
1919 assert!(!body.contains("p/child.txt"), "a sub-agent's read is not the brief's: {body}");
1920 assert!(
1921 !read_set(&messages, &[]).iter().any(|path| path == "p/child.txt"),
1922 "and it is not in the successor's read set",
1923 );
1924 }
1925
1926 #[test]
1929 fn the_files_section_names_edits_reads_and_the_uncommitted_paths() {
1930 let messages = vec![
1931 call("t1", "Read", serde_json::json!({ "file_path": "src/lib.rs" })),
1932 call("t2", "Edit", serde_json::json!({ "file_path": "src/handoff.rs" })),
1933 call("t3", "Write", serde_json::json!({ "file_path": "docs/handoff.md" })),
1934 ];
1935 let git = HandoffGitState {
1936 branch: Some("worktree-handoff-cut".to_owned()),
1937 head: Some("94cab2f20".to_owned()),
1938 dirty: vec![" M src/handoff.rs".to_owned()],
1939 dirty_total: 1,
1940 diff: vec![" src/handoff.rs | 10 +++++-----".to_owned()],
1941 };
1942 let section = files_section(&messages, &git, &[]);
1943 assert!(
1944 section.lines.contains(&"changed: src/handoff.rs, docs/handoff.md".to_owned()),
1945 "both edits are named: {:?}",
1946 section.lines,
1947 );
1948 assert!(
1949 section.lines.contains(&"read: src/lib.rs".to_owned()),
1950 "the read is named: {:?}",
1951 section.lines,
1952 );
1953 assert!(
1954 section.lines.contains(&" M src/handoff.rs".to_owned()),
1955 "the uncommitted path rides whatever git said: {:?}",
1956 section.lines,
1957 );
1958 assert!(
1959 section.lines.iter().any(|line| line.starts_with("uncommitted: 1 paths")),
1960 "and the count is stated: {:?}",
1961 section.lines,
1962 );
1963 assert!(
1964 section.lines.contains(&"diff:".to_owned()),
1965 "the probe's diff keeps its own block: {:?}",
1966 section.lines,
1967 );
1968 assert!(
1969 section.lines.iter().any(|line| line.contains("src/handoff.rs | 10")),
1970 "and carries the hunks under it: {:?}",
1971 section.lines,
1972 );
1973 }
1974
1975 #[test]
1979 fn the_ground_truth_is_exactly_the_mechanical_sections() {
1980 let messages = vec![
1981 user("go"),
1982 call("t1", "Edit", serde_json::json!({ "file_path": "src/a.rs" })),
1983 call("t2", "Read", serde_json::json!({ "file_path": "src/b.rs" })),
1984 ];
1985 let git = HandoffGitState {
1986 branch: Some("main".to_owned()),
1987 head: Some("abc".to_owned()),
1988 dirty: Vec::new(),
1989 dirty_total: 0,
1990 diff: vec![" src/a.rs | 2 ++".to_owned()],
1991 };
1992 let ground = assemble(&seat(), &messages, &git, &[], &[], live_work_sections(None));
1993 let labels: Vec<&str> = ground.sections.iter().map(|s| s.label.as_str()).collect();
1994 assert_eq!(
1995 labels,
1996 [
1997 "seat",
1998 "cwd",
1999 "branch",
2000 "role",
2001 "files",
2002 "board",
2003 "agents",
2004 "background",
2005 "monitors",
2006 "processes",
2007 "record",
2008 ],
2009 "the brief is exactly the mechanical facts",
2010 );
2011 }
2012
2013 #[test]
2017 fn a_long_file_list_keeps_the_first_and_the_newest() {
2018 let messages: Vec<Message> = (0..12)
2019 .map(|n| {
2020 call(
2021 &format!("t{n}"),
2022 "Edit",
2023 serde_json::json!({ "file_path": format!("src/f{n}.rs") }),
2024 )
2025 })
2026 .collect();
2027 let section = files_section(&messages, &HandoffGitState::default(), &[]);
2028 let line = section.lines.first().expect("the changed line");
2029 assert!(line.contains("10 shown of 12 (2 omitted)"), "the omission is stated: {line}");
2030 assert!(line.contains("src/f0.rs"), "the first is kept: {line}");
2031 assert!(line.contains("src/f11.rs"), "and the newest: {line}");
2032 assert!(!line.contains("src/f1.rs"), "the second-oldest is what the cap takes: {line}");
2033 }
2034
2035 #[test]
2038 fn the_board_section_lists_only_the_slots_own_rows_and_their_links() {
2039 let mine = SessionSlot::worker("Busytools", "forge", "handoff-cut");
2040 let rows = vec![
2041 task("the automatic handoff", Some(mine.clone()), Some("https://github.com/x/pull/9")),
2042 task(
2043 "someone else's work",
2044 Some(SessionSlot::worker("Busytools", "forge", "other")),
2045 None,
2046 ),
2047 ];
2048 let section = board_section(&mine, &rows);
2049 assert_eq!(
2050 section.lines.first().map(String::as_str),
2051 Some("the automatic handoff (in progress)"),
2052 "the owned row is drawn with its state",
2053 );
2054 assert!(
2055 section.lines.contains(&"pr: https://github.com/x/pull/9".to_owned()),
2056 "and its link: {:?}",
2057 section.lines,
2058 );
2059 assert!(
2060 !section.lines.iter().any(|line| line.contains("someone else")),
2061 "another seat's row is not the successor's work: {:?}",
2062 section.lines,
2063 );
2064 }
2065
2066 #[test]
2070 fn the_live_work_sections_list_agents_background_monitors_and_processes() {
2071 let mut domain = crate::domain_session::DomainSession::new(
2072 SessionSlot::worker("Busytools", "forge", "handoff-cut"),
2073 None,
2074 );
2075 domain.card_tracker.apply(&assistant(
2076 vec![ContentBlock::ToolUse {
2077 id: "t1".to_owned(),
2078 name: "Task".to_owned(),
2079 input: serde_json::json!({
2080 "description": "scan the pile",
2081 "subagent_type": "Explore",
2082 "prompt": "scan",
2083 }),
2084 extras: Extras::new(),
2085 }],
2086 None,
2087 ));
2088 domain.background_tasks = vec![forge_primitives::background::BackgroundTask {
2089 task_id: "b1".to_owned(),
2090 is_backgrounded: true,
2091 task_type: "local_bash".to_owned(),
2092 description: "cargo watch".to_owned(),
2093 command: Some("cargo watch".to_owned()),
2094 tool_use_id: None,
2095 }];
2096 domain.monitors = vec![forge_primitives::runtime::MonitorRecord {
2097 tool_use_id: "m1".to_owned(),
2098 task_id: Some("mt1".to_owned()),
2099 description: "watch the build".to_owned(),
2100 command: "tail -f build.log".to_owned(),
2101 persistent: false,
2102 timeout_ms: 0,
2103 status: forge_primitives::runtime::MonitorStatus::Running,
2104 output_file: None,
2105 ended_at: None,
2106 }];
2107 domain.process_snapshot = Some(forge_agent::env::processes::ProcessSnapshot {
2108 processes: vec![forge_agent::env::processes::ProcessEntry {
2109 pid: 42,
2110 parent_pid: 1,
2111 name: "cargo".to_owned(),
2112 command: "cargo nextest run".to_owned(),
2113 memory_bytes: 1024,
2114 }],
2115 scanned_at: SystemTime::UNIX_EPOCH,
2116 });
2117
2118 let sections = live_work_sections(Some(&domain));
2119 let labels: Vec<&str> = sections.iter().map(|section| section.label.as_str()).collect();
2120 assert_eq!(labels, ["agents", "background", "monitors", "processes"]);
2121 assert!(
2122 sections[0].lines[0].contains("Explore: scan the pile"),
2123 "the live sub-agent: {:?}",
2124 sections[0].lines,
2125 );
2126 assert!(
2127 sections[1].lines[0].contains("local_bash: cargo watch [b1]"),
2128 "the background task: {:?}",
2129 sections[1].lines,
2130 );
2131 assert!(
2132 sections[2].lines[0].contains("watch the build [m1]"),
2133 "the monitor: {:?}",
2134 sections[2].lines,
2135 );
2136 assert!(
2137 sections[3].lines[0].contains("42 cargo: cargo nextest run"),
2138 "the process: {:?}",
2139 sections[3].lines,
2140 );
2141
2142 let fresh = crate::domain_session::DomainSession::new(
2143 SessionSlot::worker("Busytools", "forge", "handoff-cut"),
2144 None,
2145 );
2146 let idle = live_work_sections(Some(&fresh));
2147 assert_eq!(idle[0].lines, vec!["none running".to_owned()]);
2148 assert_eq!(idle[3].lines, vec!["not scanned".to_owned()], "an absent scan is stated");
2149
2150 let mut settled = crate::domain_session::DomainSession::new(
2153 SessionSlot::worker("Busytools", "forge", "handoff-cut"),
2154 None,
2155 );
2156 settled.card_tracker.apply(&assistant(
2157 vec![ContentBlock::ToolUse {
2158 id: "t1".to_owned(),
2159 name: "Task".to_owned(),
2160 input: serde_json::json!({ "description": "scan the pile", "prompt": "scan" }),
2161 extras: Extras::new(),
2162 }],
2163 None,
2164 ));
2165 settled.card_tracker.apply(&result("t1", "done", false));
2166 settled.monitors = vec![forge_primitives::runtime::MonitorRecord {
2167 tool_use_id: "m2".to_owned(),
2168 task_id: Some("mt2".to_owned()),
2169 description: "watch the build".to_owned(),
2170 command: "tail -f build.log".to_owned(),
2171 persistent: false,
2172 timeout_ms: 0,
2173 status: forge_primitives::runtime::MonitorStatus::Stopped,
2174 output_file: None,
2175 ended_at: None,
2176 }];
2177 let done = live_work_sections(Some(&settled));
2178 assert_eq!(
2179 done[0].lines,
2180 vec!["none running".to_owned()],
2181 "a finished sub-agent is not live",
2182 );
2183 let stopped = "a stopped monitor is not live";
2184 assert_eq!(done[2].lines, vec!["none running".to_owned()], "{stopped}");
2185
2186 let absent = live_work_sections(None);
2187 assert_eq!(absent.len(), 4);
2188 for section in &absent {
2189 assert_eq!(
2190 section.lines,
2191 vec!["not read".to_owned()],
2192 "an absent domain is stated as unread, not as nothing running",
2193 );
2194 }
2195 }
2196
2197 #[test]
2201 fn the_cycle_counts_requests_turns_and_the_prompt_tokens_the_model_read() {
2202 let messages = vec![
2203 user("go"),
2204 assistant(vec![text("working")], Some(usage(100, 1_000))),
2205 result("g1", "ok", false),
2206 user("and now this"),
2207 assistant(vec![text("done")], Some(usage(200, 2_000))),
2208 ];
2209 let cycle = cycle(&messages, user_prompt_count(&messages));
2210 assert_eq!(cycle.requests, 2, "one request per usage-bearing assistant row");
2211 assert_eq!(cycle.turns, 2, "one turn per real user prompt");
2212 assert_eq!(cycle.prompt_tokens, 3_300, "input and cache reads both count");
2213 }
2214
2215 #[test]
2218 fn a_note_with_no_headings_is_one_note_section() {
2219 let raw = "the work is half done.\nthe next step is the client row.";
2220 assert!(parses_as_note(raw), "prose with no heading is the note");
2221 let sections = parse_judgment(raw);
2222 assert_eq!(sections.len(), 1, "one section, not zero");
2223 assert_eq!(sections[0].label, "note");
2224 assert_eq!(sections[0].lines.len(), 2, "and its lines survive");
2225 }
2226
2227 #[test]
2231 fn the_models_own_headings_split_the_note() {
2232 let raw = "## Where things stand\nthe cut is half in.\n\
2233 ## What failed\nthe first spawn.\n## Next\nfix the parser.";
2234 let sections = parse_judgment(raw);
2235 let labels: Vec<&str> = sections.iter().map(|section| section.label.as_str()).collect();
2236 assert_eq!(
2237 labels,
2238 ["Where things stand", "What failed", "Next"],
2239 "the note's own headings, in order",
2240 );
2241 assert_eq!(
2242 sections[1].lines,
2243 vec!["the first spawn.".to_owned()],
2244 "and each heading keeps the body under it",
2245 );
2246 }
2247
2248 #[test]
2253 fn every_heading_spelling_parses() {
2254 for (label, body) in [
2255 ("Where things stand", "the cut is half in."),
2256 ("What failed", "the first spawn, on a cold cache."),
2257 ("Next", "fix the parser."),
2258 ] {
2259 for line in [
2260 format!("{label}: {body}"),
2261 format!("## {label}: {body}"),
2262 format!("**{label}:** {body}"),
2263 format!("- **{label}:** {body}"),
2264 ] {
2265 let sections = parse_judgment(&line);
2266 assert_eq!(sections.len(), 1, "one section for {line}");
2267 assert_eq!(sections[0].label, label, "the heading for {line}");
2268 assert_eq!(sections[0].lines, vec![body.to_owned()], "its body for {line}");
2269 }
2270 for line in [
2273 format!("**{label}** {body}"),
2274 format!("- **{label}** {body}"),
2275 format!("1. **{label}** {body}"),
2276 ] {
2277 let sections = parse_judgment(&line);
2278 assert_eq!(sections.len(), 1, "one section for {line}");
2279 assert_eq!(sections[0].label, label, "the heading for {line}");
2280 assert_eq!(sections[0].lines, vec![body.to_owned()], "its body for {line}");
2281 }
2282 for line in [
2285 format!("## {label}"),
2286 format!("**{label}**"),
2287 format!("## {label}."),
2288 format!("1. **{label}**"),
2289 format!("## 1. {label}"),
2290 ] {
2291 let sections = parse_judgment(&format!("{line}\n{body}"));
2292 assert_eq!(sections.len(), 1, "one section for {line}");
2293 assert_eq!(sections[0].label, label, "the heading for {line}");
2294 assert_eq!(
2295 sections[0].lines,
2296 vec![body.to_owned()],
2297 "the body from the line below for {line}",
2298 );
2299 }
2300 }
2301 }
2302
2303 #[test]
2307 fn the_first_delimiter_ends_the_label() {
2308 let sections = parse_judgment("## Where things stand: the **cut** is in");
2309 assert_eq!(sections.len(), 1, "one section: {sections:?}");
2310 assert_eq!(sections[0].label, "Where things stand");
2311 assert_eq!(sections[0].lines, vec!["the **cut** is in".to_owned()]);
2312
2313 let sections = parse_judgment("**Next**: run the gate");
2314 assert_eq!(sections.len(), 1, "one section: {sections:?}");
2315 assert_eq!(sections[0].label, "Next");
2316 assert_eq!(sections[0].lines, vec!["run the gate".to_owned()]);
2317
2318 let sections = parse_judgment("Next: **run** the gate.");
2321 assert_eq!(sections.len(), 1, "one section: {sections:?}");
2322 assert_eq!(sections[0].label, "Next");
2323 assert_eq!(sections[0].lines, vec!["**run** the gate.".to_owned()]);
2324
2325 let sections = parse_judgment("Next: ::1 is the address");
2328 assert_eq!(sections.len(), 1, "one section: {sections:?}");
2329 assert_eq!(sections[0].label, "Next");
2330 assert_eq!(sections[0].lines, vec!["::1 is the address".to_owned()]);
2331 }
2332
2333 #[test]
2337 fn a_heading_strips_its_own_emphasis_and_leaves_the_body_s() {
2338 let sections = parse_judgment("**Now:** *nothing* has changed.");
2339 assert_eq!(sections.len(), 1, "one section");
2340 assert_eq!(sections[0].label, "Now");
2341 assert_eq!(sections[0].lines, vec!["*nothing* has changed.".to_owned()]);
2342 }
2343
2344 #[test]
2348 fn a_markdown_note_arrives_as_the_judgment_half() {
2349 let note = "\
2350## Where things stand
2351The cut is half in and the parser is next.
2352## What failed
2353worktree handoff-index-first at abc1234, no PR yet.
2354## Next
2355fix the parser, then the client row.";
2356 assert!(parses_as_note(note), "the note is recognised");
2357 let sections = parse_judgment(note);
2358 assert_eq!(
2359 sections.iter().map(|section| section.label.as_str()).collect::<Vec<_>>(),
2360 ["Where things stand", "What failed", "Next"],
2361 "the model's headings, in order",
2362 );
2363 assert_eq!(
2364 sections[0].lines,
2365 vec!["The cut is half in and the parser is next.".to_owned()],
2366 "the body under the heading",
2367 );
2368 }
2369
2370 #[test]
2374 fn any_reply_with_content_is_a_note() {
2375 let prose = "I tried the first spawn and it failed. The row is still there.";
2376 assert!(parses_as_note(prose), "prose with no heading is the note");
2377 let sections = parse_judgment(prose);
2378 assert_eq!(sections.len(), 1, "one section: {sections:?}");
2379 assert_eq!(sections[0].label, "note");
2380 assert_eq!(sections[0].lines, vec![prose.to_owned()], "the whole paragraph survives");
2381 assert!(!parses_as_note(" \n "), "an empty reply is not the note");
2382 }
2383
2384 #[test]
2388 fn a_title_shaped_label_opens_a_section_and_a_sentence_does_not() {
2389 let sections = parse_judgment("The export format is still open: row 1d14d131");
2390 assert_eq!(sections.len(), 1, "one section: {sections:?}");
2391 assert_eq!(sections[0].label, "The export format is still open");
2392 assert_eq!(sections[0].lines, vec!["row 1d14d131".to_owned()]);
2393
2394 let prose = parse_judgment("the parser fails: it drops the body.");
2395 assert_eq!(prose.len(), 1, "one section: {prose:?}");
2396 assert_eq!(prose[0].label, "note", "a lower-case sentence stays prose");
2397 assert_eq!(prose[0].lines, vec!["the parser fails: it drops the body.".to_owned()]);
2398 }
2399
2400 #[test]
2404 fn a_note_past_the_cap_states_the_cut() {
2405 let note = format!("Now: {}\nNext: {}", "x".repeat(JUDGMENT_CAP - 200), "y".repeat(400));
2406 let sections = parse_judgment(¬e);
2407 assert_eq!(sections.len(), 1, "one section survives the cut: {sections:?}");
2408 assert_eq!(sections[0].label, "Now", "and it is the note's own front: {sections:?}");
2409 assert!(
2410 sections[0].lines.first().is_some_and(|line| line.starts_with("xxx")),
2411 "the cap keeps the front of the note, not its tail: {sections:?}",
2412 );
2413 assert!(
2414 sections[0]
2415 .lines
2416 .last()
2417 .is_some_and(|line| line.contains("in the transcript named above")),
2418 "the cut is stated as the kept section's last line: {sections:?}",
2419 );
2420
2421 let huge = parse_judgment(&"x".repeat(JUDGMENT_CAP + 10));
2424 assert_eq!(huge.len(), 1, "the cut is stated: {huge:?}");
2425 assert_eq!(huge[0].label, "note", "the fallback is the note's own label");
2426 assert!(
2427 huge[0].lines[0].contains("longer than the brief allows"),
2428 "and says what happened: {huge:?}",
2429 );
2430 }
2431
2432 #[test]
2435 fn the_brief_puts_the_ground_truth_before_the_judgment() {
2436 let brief = HandoffBrief {
2437 ground_truth: vec![HandoffSection {
2438 label: "seat".to_owned(),
2439 lines: vec!["Busytools/forge/lead".to_owned()],
2440 }],
2441 judgment: vec![HandoffSection {
2442 label: "Next".to_owned(),
2443 lines: vec!["the client row".to_owned()],
2444 }],
2445 };
2446 let text = brief_text("9cfe315c -> 4b21e0a7", &brief);
2447 let authority = text
2448 .find("PART 1 is forge's own ground truth and is authoritative for facts")
2449 .expect("the precedence is stated");
2450 let ground = text.find("PART 1: GROUND TRUTH").expect("part 1");
2451 let judgment = text.find("PART 2: JUDGMENT").expect("part 2");
2452 assert!(
2453 authority < ground,
2454 "the successor is told which half to trust before it reads either: {text}",
2455 );
2456 assert!(ground < judgment, "ground truth first: {text}");
2457 assert!(text.contains("seat: Busytools/forge/lead"), "the facts are rendered");
2458 assert!(text.contains("Next: the client row"), "and so is the judgment");
2459 assert!(
2460 text.contains("PART 2: JUDGMENT (written by the session that is handing over)"),
2461 "a note the session wrote names it as the author: {text}",
2462 );
2463
2464 let sourced = HandoffBrief {
2468 ground_truth: Vec::new(),
2469 judgment: vec![
2470 HandoffSection {
2471 label: "source".to_owned(),
2472 lines: vec![
2473 "the exit call carried no note; this is the session's closing \
2474 text"
2475 .to_owned(),
2476 ],
2477 },
2478 HandoffSection {
2479 label: "note".to_owned(),
2480 lines: vec!["the cut is half in".to_owned()],
2481 },
2482 ],
2483 };
2484 let text = brief_text("9cfe315c -> 4b21e0a7", &sourced);
2485 assert!(
2486 text.contains("the source row is forge's; the rest is the session's own words"),
2487 "the header names the exception: {text}",
2488 );
2489 assert!(
2490 text.contains("apart from the source row forge added"),
2491 "and so does the precedence line: {text}",
2492 );
2493 }
2494
2495 #[test]
2500 fn the_brief_forges_the_successors_procedure_row() {
2501 use crate::protocol::PromptSource;
2502
2503 let (workspace, mut rx) = crate::Workspace::testing_stub();
2504 workspace.enable_test_dispatch_intercept();
2505 let slot = SessionSlot::lead("Busytools", "forge");
2506
2507 workspace
2508 .dispatch_handoff_brief(&slot, "HANDOFF - a -> b".to_owned(), "brief-1".to_owned())
2509 .expect("the brief is routed");
2510
2511 let routed = workspace.drain_test_dispatch_buffer();
2512 assert!(
2513 routed.iter().any(|command| matches!(
2514 command,
2515 Command::PromptUnder { text, source: PromptSource::Forge, .. }
2516 if text.starts_with("HANDOFF - ")
2517 )),
2518 "the successor runs it as forge's first turn: {routed:?}",
2519 );
2520 let mut drawn = Vec::new();
2521 while let Ok(frame) = rx.try_recv() {
2522 if let SessionUpdate::ChatAppended { msg, source, .. } = &*frame.update {
2523 drawn.push((msg.clone(), *source));
2524 }
2525 }
2526 let [(msg, source)] = drawn.as_slice() else {
2527 panic!("the brief draws one row: {drawn:?}");
2528 };
2529 assert_eq!(*source, Some(PromptSource::Forge), "the machinery's own words");
2530 let forge_primitives::Message::User { message, uuid, .. } = msg else {
2531 panic!("the row is the user turn it draws: {msg:?}");
2532 };
2533 assert_eq!(uuid.as_deref(), Some("brief-1"), "under the id the brief was sent with");
2534 assert!(
2535 matches!(
2536 &message.content[0],
2537 forge_primitives::ContentBlock::Text { text, .. } if text.starts_with("HANDOFF - ")
2538 ),
2539 "carrying the brief the successor arrived by: {message:?}",
2540 );
2541 }
2542
2543 #[test]
2548 fn a_handoff_is_in_flight_from_its_begin_until_the_swap_closes() {
2549 let (workspace, _rx) = crate::Workspace::testing_stub();
2550 let slot = SessionSlot::lead("TestOrg", "forge");
2551 workspace.register_domain_session(slot.clone(), None);
2552
2553 assert!(!workspace.handoff_in_flight(&slot), "an idle seat takes input");
2554
2555 workspace.domain_session_for(&slot).expect("the domain").lock().handoff.awaiting = true;
2556 assert!(workspace.handoff_in_flight(&slot), "the judgment turn owns it");
2557 workspace.domain_session_for(&slot).expect("the domain").lock().handoff.awaiting = false;
2558
2559 workspace.begin_swap(&slot);
2560 assert!(workspace.handoff_in_flight(&slot), "the swap's build and spawn own it");
2561 workspace.end_swap(&slot);
2562 assert!(!workspace.handoff_in_flight(&slot), "the successor hands the seat back");
2563 }
2564
2565 #[test]
2569 fn a_machinery_prompt_waits_for_the_successor() {
2570 use crate::protocol::PromptSource;
2571
2572 let (workspace, _rx) = crate::Workspace::testing_stub();
2573 let slot = SessionSlot::lead("Busytools", "forge");
2574 workspace.begin_swap(&slot);
2575
2576 workspace
2577 .dispatch_workspace_prompt_under(
2578 &slot,
2579 "the cron fired".to_owned(),
2580 PromptSource::Cron,
2581 "cron-1".to_owned(),
2582 )
2583 .expect("a held prompt is an accepted prompt");
2584
2585 let held = workspace.end_swap(&slot);
2586 assert_eq!(held.len(), 1, "the delivery is held, not run: {held:?}");
2587 assert_eq!(held[0].text, "the cron fired");
2588 assert_eq!(held[0].source, PromptSource::Cron, "and it keeps who sent it");
2589 assert!(!workspace.swap_in_progress(&slot), "the gate closes with the take");
2590 }
2591
2592 fn swap_request(slot: &SessionSlot, token: Option<u64>) -> SwapRequest {
2593 SwapRequest {
2594 slot: slot.clone(),
2595 from: "old-session-id".to_owned(),
2596 to: "new-session-id".to_owned(),
2597 token,
2598 reason: forge_primitives::HandoffReason::Manual,
2599 fill: None,
2600 at_percent: 85,
2601 role: Role::Lead,
2602 judgment: Vec::new(),
2603 cwd: "/tmp/handoff-build".to_owned(),
2604 launch_settings: crate::SessionLaunchSettings::default(),
2605 }
2606 }
2607
2608 #[tokio::test]
2612 async fn a_successor_replaces_the_seats_tabs_before_it_starts() {
2613 let (workspace, _rx) = crate::Workspace::testing_stub();
2614 workspace.enable_test_dispatch_intercept();
2615 let (calls, mut heard) = tokio::sync::mpsc::unbounded_channel();
2616 workspace
2617 .install_browser_host(std::sync::Arc::new(crate::browser::testing::StubHost { calls }));
2618 let slot = SessionSlot::lead("Busytools", "forge");
2619 let token = workspace.begin_swap(&slot);
2620
2621 Arc::clone(&workspace).run_handoff(swap_request(&slot, Some(token))).await;
2622
2623 let (what, args) = tokio::time::timeout(std::time::Duration::from_secs(2), heard.recv())
2624 .await
2625 .expect("the fleet hears the replacement")
2626 .expect("a replacement");
2627 assert_eq!(what, "replace_seat", "the seat is replaced: {args}");
2628 assert_eq!(args, serde_json::json!(slot));
2629 assert!(
2630 workspace
2631 .drain_test_dispatch_buffer()
2632 .iter()
2633 .any(|command| matches!(command, Command::NewSession { .. })),
2634 "and the successor is dispatched after it",
2635 );
2636 }
2637
2638 #[test]
2641 fn a_new_swap_invalidates_the_previous_token() {
2642 let (workspace, _rx) = crate::Workspace::testing_stub();
2643 let slot = SessionSlot::lead("Busytools", "forge");
2644 let first = workspace.begin_swap(&slot);
2645 assert!(workspace.swap_is_current(&slot, first), "the opener owns the gate");
2646
2647 let second = workspace.begin_swap(&slot);
2648 assert!(!workspace.swap_is_current(&slot, first), "the replacement owns the seat now");
2649 assert!(workspace.swap_is_current(&slot, second));
2650
2651 workspace.end_swap(&slot);
2652 assert!(!workspace.swap_is_current(&slot, second), "a closed gate owns nothing");
2653 }
2654
2655 #[tokio::test]
2660 async fn a_build_that_loses_the_seat_writes_nothing() {
2661 use std::task::{Context, Poll, Waker};
2662
2663 let (workspace, mut rx) = crate::Workspace::testing_stub();
2664 workspace.enable_test_dispatch_intercept();
2665 let (calls, mut heard) = tokio::sync::mpsc::unbounded_channel();
2666 workspace
2667 .install_browser_host(std::sync::Arc::new(crate::browser::testing::StubHost { calls }));
2668 let slot = SessionSlot::lead("Busytools", "forge");
2669 let token = workspace.begin_swap(&slot);
2670 let mut build =
2671 Box::pin(Arc::clone(&workspace).run_handoff(swap_request(&slot, Some(token))));
2672
2673 let waker = Waker::noop();
2675 let mut cx = Context::from_waker(waker);
2676 assert!(
2677 matches!(build.as_mut().poll(&mut cx), Poll::Pending),
2678 "precondition: the build suspended on its read",
2679 );
2680 workspace.begin_swap(&slot);
2682 build.await;
2683
2684 assert!(
2685 !workspace
2686 .drain_test_dispatch_buffer()
2687 .iter()
2688 .any(|command| matches!(command, Command::NewSession { .. })),
2689 "no successor is spawned for a seat the build no longer owns",
2690 );
2691 assert!(
2692 workspace.pending_brief_for(&slot).is_none(),
2693 "and no brief is left waiting for a successor that was never started",
2694 );
2695 assert!(
2696 tokio::time::timeout(std::time::Duration::from_millis(100), heard.recv())
2697 .await
2698 .is_err(),
2699 "a lost seat's tabs are not replaced",
2700 );
2701 let mut turns = Vec::new();
2702 while let Ok(frame) = rx.try_recv() {
2703 assert!(
2704 !matches!(&*frame.update, SessionUpdate::Handoff { .. }),
2705 "no handoff row is drawn for it: {:?}",
2706 frame.update,
2707 );
2708 if let SessionUpdate::MachineTurn { machine, .. } =
2709 std::sync::Arc::unwrap_or_clone(frame.update)
2710 {
2711 turns.push(machine);
2712 }
2713 }
2714 assert_eq!(turns, vec![None], "the lost build hands the machine turn back");
2717 }
2718
2719 fn machine_turns(
2721 rx: &mut tokio::sync::mpsc::UnboundedReceiver<crate::update_fanout::SeatUpdate>,
2722 ) -> Vec<Option<forge_primitives::runtime::MachineTurn>> {
2723 let mut out = Vec::new();
2724 while let Ok(frame) = rx.try_recv() {
2725 if let SessionUpdate::MachineTurn { machine, .. } =
2726 std::sync::Arc::unwrap_or_clone(frame.update)
2727 {
2728 out.push(machine);
2729 }
2730 }
2731 out
2732 }
2733
2734 #[tokio::test]
2738 async fn a_build_that_never_starts_hands_the_machine_turn_back() {
2739 let slot = SessionSlot::lead("TestOrg", "forge");
2740
2741 let (workspace, mut rx) = crate::Workspace::testing_stub();
2743 Arc::clone(&workspace).run_handoff(swap_request(&slot, None)).await;
2744 assert_eq!(machine_turns(&mut rx), vec![None], "no token, no machine turn");
2745
2746 let (workspace, mut rx) = crate::Workspace::testing_stub();
2748 let token = workspace.begin_swap(&slot);
2749 workspace.begin_swap(&slot);
2750 Arc::clone(&workspace).run_handoff(swap_request(&slot, Some(token))).await;
2751 assert_eq!(machine_turns(&mut rx), vec![None], "a seat lost before the build");
2752 }
2753
2754 #[tokio::test]
2758 async fn a_build_whose_successor_cannot_start_hands_the_machine_turn_back() {
2759 let (workspace, mut rx) = crate::Workspace::testing_stub();
2760 let slot = SessionSlot::lead("TestOrg", "forge");
2761 let token = workspace.begin_swap(&slot);
2762
2763 Arc::clone(&workspace).run_handoff(swap_request(&slot, Some(token))).await;
2764
2765 assert_eq!(machine_turns(&mut rx), vec![None], "the successor never started");
2766 }
2767
2768 #[test]
2772 fn the_successor_counts_skip_the_brief_row() {
2773 let messages = vec![
2774 user("HANDOFF - 9cfe315c -> 4b21e0a7\n\nPART 2: JUDGMENT\ndo not delete the row"),
2775 user("carry on"),
2776 user("actually, keep the row"),
2777 ];
2778 let counts = successor_counts(&messages, &[]);
2779 assert_eq!(counts.turns, 2, "two turns, not the brief");
2780 assert_eq!(counts.corrections, 1, "and the correction is the later prompt: {counts:?}");
2781 }
2782
2783 #[test]
2786 fn an_empty_read_measures_nothing() {
2787 assert!(
2788 successor_measurement(&[], &[]).is_none(),
2789 "an empty read is a failure, not a perfect successor",
2790 );
2791 assert!(
2792 successor_measurement(&[user("carry on")], &[]).is_some(),
2793 "a read with a turn in it measures",
2794 );
2795 assert!(
2796 successor_measurement(
2797 &[
2798 user("HANDOFF - old -> new\n\nPART 1: GROUND TRUTH"),
2799 assistant(Vec::new(), None),
2800 ],
2801 &[],
2802 )
2803 .is_none(),
2804 "a read holding only the brief and the assistant's rows measures nothing",
2805 );
2806 }
2807
2808 #[tokio::test]
2811 async fn an_absent_transcript_is_stated_in_the_ground_truth() {
2812 let dir = tempfile::tempdir().expect("tempdir");
2813 let transcript_path = dir.path().join("not-there.jsonl");
2814 let seat = HandoffSeat {
2815 slot: &SessionSlot::worker("Busytools", "forge", "handoff-cut"),
2816 from: "missing-session",
2817 cwd: dir.path(),
2818 transcript_path: &transcript_path,
2819 role: Role::Worker { charter_opening: None },
2820 };
2821 let ground = ground_truth(dir.path(), &seat, &[], Vec::new()).await;
2822 assert_eq!(ground.sections[0].label, "note", "the failure leads the brief");
2823 assert!(
2824 ground.sections[0].lines[0].contains("could not be read"),
2825 "and says what it is: {:?}",
2826 ground.sections[0].lines,
2827 );
2828 }
2829
2830 #[test]
2834 fn a_reread_is_a_file_the_predecessor_had_read() {
2835 let messages = vec![
2836 call("r1", "Read", serde_json::json!({ "file_path": "src/a.rs" })),
2837 call("r2", "Read", serde_json::json!({ "file_path": "src/b.rs" })),
2838 ];
2839 let counts = successor_counts(&messages, &["src/a.rs".to_owned()]);
2840 assert_eq!(counts.reread_files, 1, "only the file both sessions read: {counts:?}");
2841 }
2842
2843 #[test]
2846 fn questions_and_corrections_are_counted() {
2847 let messages = vec![
2848 user("HANDOFF - brief"),
2849 user("go on"),
2850 call("q1", "AskUserQuestion", serde_json::json!({ "questions": [] })),
2851 user("as i said, the server row first"),
2852 ];
2853 let counts = successor_counts(&messages, &[]);
2854 assert_eq!(counts.questions, 1, "the ask is counted");
2855 assert_eq!(counts.corrections, 1, "and the repeat is a correction: {counts:?}");
2856 }
2857
2858 #[test]
2861 fn the_threshold_is_compared_before_rounding() {
2862 let just_under = FillReading { percent: 85, total_tokens: 846_000, max_tokens: 1_000_000 };
2863 assert!(!just_under.fires(85), "84.6% is under the line");
2864 let at = FillReading { percent: 85, total_tokens: 850_000, max_tokens: 1_000_000 };
2865 assert!(at.fires(85), "exactly 85% is at it");
2866 let share_only = FillReading { percent: 85, total_tokens: 0, max_tokens: 0 };
2867 assert!(share_only.fires(85), "a share-only reading falls back to its share");
2868 }
2869
2870 #[test]
2873 fn the_judgment_prompt_runs_memory_then_the_exit() {
2874 let prompt = judgment_prompt();
2875 assert!(
2876 prompt.contains(
2877 "memory first, then close out what is running, then call `agents__handoff`"
2878 ),
2879 "the order is stated: memory, close out, exit",
2880 );
2881 assert!(
2882 prompt.find("Invoke the handoff skill").expect("the skill is named")
2883 < prompt.find("Write the continuation").expect("the contract follows"),
2884 "the skill runs before the note's contract",
2885 );
2886 assert!(
2887 prompt.contains("This note is Step 2's handoff document")
2888 && prompt.contains("pass the same text to `agents__handoff` as `brief`"),
2889 "the contract names the skill's document as the one note, not a separate file",
2890 );
2891 }
2892
2893 #[test]
2898 fn the_contract_asks_for_the_continuation_in_the_models_own_shape() {
2899 let prompt = judgment_prompt();
2900 for clause in [
2901 "in whatever shape reads best",
2902 "there is no set of headings to fill in",
2903 "where things stand",
2904 "what is done and verified",
2905 "what is in flight",
2906 "the row, PR, branch or session",
2907 "what you tried that failed",
2908 "the exact next action",
2909 "Point at what you recorded instead of retelling it",
2910 "the memory files by name",
2911 "Never write a key, token, password or personal data",
2912 ] {
2913 assert!(prompt.contains(clause), "the contract drops '{clause}'");
2914 }
2915 for heading in ["Now", "Where it stood", "Pointers", "Next", "Done means"] {
2916 assert!(!prompt.contains(heading), "the contract prescribes '{heading}' again");
2917 }
2918 }
2919
2920 #[test]
2926 fn the_shipped_skill_keeps_the_whole_handoff_procedure() {
2927 let flat = SKILL.split_whitespace().collect::<Vec<_>>().join(" ");
2929 for (clause, why) in [
2930 ("## Step 1 - Memory first (REQUIRED, before any handoff text)", "his memory step"),
2931 ("## Where the handoff goes: lead or worker", "the seat decides the destination"),
2932 ("workers/<label>.md` in the same memory directory", "a worker writes its own file"),
2933 ("A worker writes no index line", "a worker writes no index line"),
2934 ("update it in place and delete superseded narration", "in-place updates"),
2935 (
2936 "Cross-project patterns or workarounds -> TIL files (`til/`), no need to ask.",
2937 "TILs",
2938 ),
2939 ("Update the memory index (`MEMORY.md`) in the SAME edit", "index in the same edit"),
2940 ("A learning that exists only in the handoff is not recorded", "memory over handoff"),
2941 ("The file is `handoff.md` in that directory", "the document he writes"),
2942 (
2943 "Write the continuation the next session needs, in your own shape",
2944 "the shape is its author's",
2945 ),
2946 ("There is no set of parts or headings to fill in", "no prescribed parts"),
2947 ("what is done and verified", "the verification clause"),
2948 ("the board row, PR, branch or session", "in-flight names its id"),
2949 ("what you tried that failed, and what stopped it", "dead ends are named"),
2950 (
2951 "the exact next action, and the file, command or row it starts at",
2952 "the next action starts somewhere",
2953 ),
2954 ("Redact sensitive information (API keys, passwords, PII).", "redaction"),
2955 ("SHIPPED / IN FLIGHT / BLOCKED", "the index token"),
2956 ("## Forge: close out and exit", "the forge addition"),
2957 ("record in memory what it is and how to resume it", "the close-out step"),
2958 ("Leave nothing unfinished unrecorded", "unfinished work is named, not dropped"),
2959 ("pass the same text to `agents__handoff` as `brief`", "the exit call"),
2960 ("there are no headings to fill in", "forge prescribes no headings"),
2961 ("Memory first, then close out what is running, then the note.", "the order"),
2962 ] {
2963 let clause = clause.split_whitespace().collect::<Vec<_>>().join(" ");
2964 assert!(flat.contains(&clause), "the shipped skill drops '{clause}' ({why})");
2965 }
2966 for heading in ["Where it stood", "Pointers", "Done means"] {
2967 assert!(!flat.contains(heading), "the shipped skill prescribes '{heading}' again");
2968 }
2969 }
2970
2971 #[test]
2976 fn the_judgment_prompt_is_a_machine_row() {
2977 let prompt = judgment_prompt();
2978 assert!(
2979 judgment_opening().starts_with(MACHINE_PREFIX),
2980 "the opening carries the matcher's prefix",
2981 );
2982 assert!(
2983 is_machine_prompt(&user(&prompt)),
2984 "the matcher recognises the real prompt as forge's own row",
2985 );
2986 assert!(
2987 !prompt.contains("context is nearly full"),
2988 "the opening is true for /handoff as well as the fill trigger",
2989 );
2990 }
2991
2992 #[test]
2995 fn the_judgment_prompt_names_no_path() {
2996 let prompt = judgment_prompt();
2997 assert!(
2998 prompt.contains("Invoke the handoff skill"),
2999 "the compiled-in prompt is the real one, not an empty string",
3000 );
3001 for (token, why) in [
3002 ("CLAUDE", "one machine's instruction file"),
3003 ("MEMORY.md", "one machine's memory index"),
3004 ("~", "a home-relative path"),
3005 ("/", "a path at all"),
3006 ] {
3007 assert!(!prompt.contains(token), "the judgment prompt names '{token}' ({why})");
3008 }
3009 }
3010
3011 fn skill_named(name: &str) -> &'static ShippedSkill {
3012 shipped_skills().iter().find(|skill| skill.name == name).expect("a shipped skill")
3013 }
3014
3015 #[test]
3018 fn every_shipped_skill_installs_all_its_files() {
3019 let dir = tempfile::tempdir().expect("tempdir");
3020 let names: Vec<&str> = shipped_skills().iter().map(|skill| skill.name).collect();
3021 assert_eq!(names, ["handoff", "wizard"], "the shipped set is both skills");
3022
3023 for skill in shipped_skills() {
3024 assert_eq!(
3025 install_skill(dir.path(), skill).expect("install"),
3026 SkillInstall::Created,
3027 "{} is created",
3028 skill.name,
3029 );
3030 for (name, contents) in skill.files {
3031 let path = skill_dir(dir.path(), skill.name).join(name);
3032 assert_eq!(
3033 std::fs::read(&path).expect("read"),
3034 contents.as_bytes(),
3035 "{}/{} is the asset, byte for byte",
3036 skill.name,
3037 name,
3038 );
3039 }
3040 }
3041 assert_eq!(
3042 std::fs::read(skill_path(dir.path(), "handoff")).expect("read"),
3043 SKILL.as_bytes(),
3044 "handoff's installed bytes are still its own asset",
3045 );
3046 }
3047
3048 #[test]
3051 fn a_second_install_touches_nothing_and_an_edit_replaces_only_its_skill() {
3052 let dir = tempfile::tempdir().expect("tempdir");
3053 for skill in shipped_skills() {
3054 install_skill(dir.path(), skill).expect("install");
3055 }
3056 let handoff = skill_path(dir.path(), "handoff");
3057 let before = std::fs::metadata(&handoff).expect("stat").modified().expect("mtime");
3058 for skill in shipped_skills() {
3059 assert_eq!(
3060 install_skill(dir.path(), skill).expect("install"),
3061 SkillInstall::Unchanged,
3062 "{} is unchanged",
3063 skill.name,
3064 );
3065 }
3066 let after = std::fs::metadata(&handoff).expect("stat").modified().expect("mtime");
3067 assert_eq!(before, after, "an identical skill is not rewritten");
3068
3069 let mut bytes = std::fs::read(&handoff).expect("read");
3072 let last = bytes.len() - 1;
3073 bytes[last] ^= 0x01;
3074 std::fs::write(&handoff, &bytes).expect("one byte off");
3075 assert_eq!(
3076 install_skill(dir.path(), skill_named("wizard")).expect("install"),
3077 SkillInstall::Unchanged,
3078 "the other skill is untouched",
3079 );
3080 assert_eq!(
3081 install_skill(dir.path(), skill_named("handoff")).expect("install"),
3082 SkillInstall::Replaced,
3083 );
3084 assert_eq!(
3085 std::fs::read(&handoff).expect("read"),
3086 SKILL.as_bytes(),
3087 "replaced with the asset"
3088 );
3089 }
3090
3091 #[test]
3097 fn the_sweep_takes_only_its_own_temps_and_retired_names() {
3098 let dir = tempfile::tempdir().expect("tempdir");
3099 let retired =
3100 ShippedSkill { name: "wizard", files: WIZARD_FILES, retired: &["old-stage.sh"] };
3101 install_skill(dir.path(), &retired).expect("install");
3102 let root = skill_dir(dir.path(), "wizard");
3103 std::fs::write(root.join("SKILL.md.tmp"), "half a write").expect("temp");
3104 std::fs::write(root.join("old-stage.sh"), "#!/bin/sh\n").expect("retired");
3105 std::fs::write(root.join("note.md"), "mine").expect("user file");
3106 std::fs::write(root.join("someone-else.tmp"), "theirs").expect("foreign temp");
3107 std::fs::create_dir(root.join("helpers")).expect("user dir");
3108 std::fs::write(root.join("helpers").join("old-stage.sh"), "keep").expect("inside a dir");
3109
3110 assert_eq!(
3111 install_skill(dir.path(), &retired).expect("install"),
3112 SkillInstall::Swept,
3113 "a sweep alone is not a rewrite",
3114 );
3115 assert!(!root.join("SKILL.md.tmp").exists(), "its own leftover temp is swept");
3116 assert!(!root.join("old-stage.sh").exists(), "a retired name is swept");
3117 assert!(root.join("note.md").exists(), "the person's file survives");
3118 assert!(root.join("someone-else.tmp").exists(), "a foreign temp is not forge's");
3119 assert!(root.join("helpers").join("old-stage.sh").exists(), "a subdirectory is theirs");
3120 }
3121
3122 #[test]
3125 fn a_name_the_asset_still_ships_is_never_swept() {
3126 let dir = tempfile::tempdir().expect("tempdir");
3127 let retired =
3128 ShippedSkill { name: "wizard", files: WIZARD_FILES, retired: &["template.sh"] };
3129 install_skill(dir.path(), &retired).expect("install");
3130 assert_eq!(
3131 install_skill(dir.path(), &retired).expect("install"),
3132 SkillInstall::Unchanged,
3133 "a shipped file listed as retired is still not swept",
3134 );
3135 assert!(
3136 skill_dir(dir.path(), "wizard").join("template.sh").exists(),
3137 "the shipped file is there",
3138 );
3139 }
3140
3141 #[test]
3144 fn a_shipped_skill_sweeps_nothing_it_did_not_retire() {
3145 let dir = tempfile::tempdir().expect("tempdir");
3146 let wizard = skill_named("wizard");
3147 install_skill(dir.path(), wizard).expect("install");
3148 let user_file = skill_dir(dir.path(), "wizard").join("old-stage.sh");
3149 std::fs::write(&user_file, "#!/bin/sh\n").expect("user file");
3150 assert_eq!(
3151 install_skill(dir.path(), wizard).expect("install"),
3152 SkillInstall::Unchanged,
3153 "an unknown file is not the skill's to sweep",
3154 );
3155 assert!(user_file.exists(), "and it is still there");
3156 }
3157
3158 #[test]
3161 fn a_deleted_file_comes_back_on_the_next_install() {
3162 let dir = tempfile::tempdir().expect("tempdir");
3163 let wizard = skill_named("wizard");
3164 install_skill(dir.path(), wizard).expect("install");
3165 let template = skill_dir(dir.path(), "wizard").join("template.sh");
3166 std::fs::remove_file(&template).expect("delete");
3167 assert_eq!(install_skill(dir.path(), wizard).expect("install"), SkillInstall::Replaced);
3168 assert_eq!(std::fs::read(&template).expect("read"), WIZARD_TEMPLATE.as_bytes());
3169 }
3170
3171 #[test]
3175 fn a_second_install_rewrites_only_the_file_that_differs() {
3176 let dir = tempfile::tempdir().expect("tempdir");
3177 let wizard = skill_named("wizard");
3178 install_skill(dir.path(), wizard).expect("install");
3179 let root = skill_dir(dir.path(), "wizard");
3180 let template = root.join("template.sh");
3181 let before = std::fs::metadata(&template).expect("stat").modified().expect("mtime");
3182 let skill_md = root.join("SKILL.md");
3183 let mut bytes = std::fs::read(&skill_md).expect("read");
3184 let last = bytes.len() - 1;
3185 bytes[last] ^= 0x01;
3186 std::fs::write(&skill_md, bytes).expect("edit");
3187 assert_eq!(install_skill(dir.path(), wizard).expect("install"), SkillInstall::Replaced);
3188 let after = std::fs::metadata(&template).expect("stat").modified().expect("mtime");
3189 assert_eq!(before, after, "the unchanged file is left alone");
3190 assert_eq!(std::fs::read(&skill_md).expect("read"), WIZARD_SKILL.as_bytes());
3191 }
3192
3193 #[test]
3196 fn an_unreadable_skill_file_is_an_error_and_leaves_the_bytes_alone() {
3197 use std::os::unix::fs::PermissionsExt;
3198 let dir = tempfile::tempdir().expect("tempdir");
3199 let skill_md = skill_path(dir.path(), "handoff");
3200 std::fs::create_dir_all(skill_md.parent().expect("parent")).expect("dir");
3201 std::fs::write(&skill_md, b"mine").expect("write");
3202 std::fs::set_permissions(&skill_md, std::fs::Permissions::from_mode(0o000)).expect("mode");
3203 let result = install_skill(dir.path(), skill_named("handoff"));
3204 std::fs::set_permissions(&skill_md, std::fs::Permissions::from_mode(0o644)).expect("mode");
3206
3207 let error = result.expect_err("an unreadable skill file is an error");
3208 assert_eq!(
3209 error.kind(),
3210 std::io::ErrorKind::PermissionDenied,
3211 "the read's own error, not a later write: {error}",
3212 );
3213 assert_eq!(std::fs::read(&skill_md).expect("read"), b"mine", "the bytes are untouched");
3214 }
3215
3216 #[test]
3219 fn a_failed_sweep_does_not_fail_the_install() {
3220 use std::os::unix::fs::PermissionsExt;
3221 let dir = tempfile::tempdir().expect("tempdir");
3222 let wizard = skill_named("wizard");
3223 install_skill(dir.path(), wizard).expect("install");
3224 let root = skill_dir(dir.path(), "wizard");
3225 let leftover = root.join("SKILL.md.tmp");
3226 std::fs::write(&leftover, "half a write").expect("temp");
3227 std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o555)).expect("mode");
3228 let result = install_skill(dir.path(), wizard);
3229 std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755)).expect("mode");
3230
3231 assert_eq!(
3232 result.expect("a failed removal is not an install failure"),
3233 SkillInstall::Swept,
3234 "the content was already current; only the sweep was owed",
3235 );
3236 assert!(leftover.exists(), "the leftover is left for the next boot");
3237 }
3238
3239 #[test]
3243 fn a_staged_write_that_fails_leaves_every_file_as_it_was() {
3244 let dir = tempfile::tempdir().expect("tempdir");
3245 let wizard = skill_named("wizard");
3246 install_skill(dir.path(), wizard).expect("install");
3247 let root = skill_dir(dir.path(), "wizard");
3248 let skill_md = root.join("SKILL.md");
3249 let template = root.join("template.sh");
3250 let edited = b"edited by the person".to_vec();
3251 std::fs::write(&skill_md, &edited).expect("edit");
3252 std::fs::write(&template, b"edited too").expect("edit");
3253 std::fs::create_dir(root.join("template.sh.tmp")).expect("block the second temp");
3255
3256 assert!(install_skill(dir.path(), wizard).is_err(), "the staged write fails");
3257 assert_eq!(std::fs::read(&skill_md).expect("read"), edited, "nothing was renamed");
3258 assert!(!root.join("SKILL.md.tmp").exists(), "the staged temp is cleaned up");
3259 }
3260
3261 #[test]
3264 fn a_symlinked_skill_directory_is_refused_and_left_alone() {
3265 use std::os::unix::fs::symlink;
3266 let dir = tempfile::tempdir().expect("tempdir");
3267 let outside = tempfile::tempdir().expect("outside");
3268 std::fs::write(outside.path().join("SKILL.md.tmp"), "half a write").expect("leftover");
3269 std::fs::create_dir_all(dir.path().join("skills")).expect("skills dir");
3270 symlink(outside.path(), skill_dir(dir.path(), "wizard")).expect("symlink");
3271
3272 let error = install_skill(dir.path(), skill_named("wizard")).expect_err("refused");
3273 assert_eq!(
3274 error.kind(),
3275 std::io::ErrorKind::InvalidInput,
3276 "a refusal, not a write: {error}",
3277 );
3278 assert!(outside.path().join("SKILL.md.tmp").exists(), "nothing outside is swept");
3279 assert!(!outside.path().join("SKILL.md").exists(), "nothing outside is written");
3280 }
3281
3282 #[test]
3285 fn the_prompt_invokes_the_skill_by_its_shipped_name() {
3286 let name = SKILL
3287 .lines()
3288 .find_map(|line| line.strip_prefix("name:"))
3289 .map(str::trim)
3290 .expect("the skill declares its name");
3291 assert!(
3292 judgment_prompt().contains(&format!("Invoke the {name} skill")),
3293 "the prompt names '{name}', the frontmatter's own name",
3294 );
3295 assert_eq!(
3296 skill_path(Path::new("/tmp/cfg"), name),
3297 Path::new("/tmp/cfg").join("skills").join(name).join("SKILL.md"),
3298 "and the install directory is that name",
3299 );
3300 }
3301
3302 #[test]
3307 fn the_wizard_asset_is_whole() {
3308 assert!(WIZARD_SKILL.len() > 1_000, "the skill is a document, not a placeholder");
3309 assert!(WIZARD_TEMPLATE.len() > 1_000, "the template carries the library");
3310 let name = WIZARD_SKILL
3311 .lines()
3312 .find_map(|line| line.strip_prefix("name:"))
3313 .map(str::trim)
3314 .expect("the skill declares its name");
3315 assert_eq!(name, "wizard");
3316 assert_eq!(
3317 skill_path(Path::new("/tmp/cfg"), name),
3318 Path::new("/tmp/cfg").join("skills").join("wizard").join("SKILL.md"),
3319 "the install directory is the frontmatter's name",
3320 );
3321 let flat = WIZARD_SKILL.split_whitespace().collect::<Vec<_>>().join(" ");
3324 for (clause, why) in [
3325 ("terminal__open", "the seat's own terminal"),
3326 ("terminal__hand_off", "the human-only hand-off"),
3327 ("`terminal__read` and `terminal__write` are refused", "the blind window"),
3328 ("browser_navigate", "the seat's browser"),
3329 ("browser_hand_off", "the tab hand-off"),
3330 ("`open_url` in the script is the fallback", "the no-browser fallback"),
3331 ("`settings__get`", "credentials: settings first"),
3332 (
3333 "when it is a stored credential and your tool list carries the vault family",
3334 "the \
3335 vault is conditional on the family",
3336 ),
3337 ("If the family was withheld from this session, ask your lead", "the withheld family"),
3338 ("If forge has no `[vault]` configured", "the unconfigured vault"),
3339 ("`ask_secret` in the terminal", "then hidden entry"),
3340 ("Never ask for a secret in chat", "no secret in chat"),
3341 ("never embedded in the script", "a found value is not written into the script"),
3342 (
3343 "the agent types it at the stage's own prompt with `terminal__write`",
3344 "the prompt's answer",
3345 ),
3346 ("`gh variable`", "the CI destination set"),
3347 ("`wizards/<slug>.md`", "the stage file a handoff resumes"),
3348 ("a worker in its own `workers/<label>.md`", "the seat rule in the opening"),
3349 ("a worker keeps it in its own `workers/<label>.md`", "the seat rule"),
3350 ("if confirm \"Delete the old webhook?\"", "the confirm idiom"),
3351 ("the `handoff` skill's", "the handoff pointer, not a copy of it"),
3352 ("never invent UI steps", "the no-invented-UI boundary"),
3353 ] {
3354 let clause = clause.split_whitespace().collect::<Vec<_>>().join(" ");
3355 assert!(flat.contains(&clause), "the wizard skill drops '{clause}' ({why})");
3356 }
3357 assert!(
3358 WIZARD_TEMPLATE.contains("Wizard library") && WIZARD_TEMPLATE.contains("STAGES"),
3359 "the template keeps its library and its authoring marker",
3360 );
3361 assert!(
3362 WIZARD_TEMPLATE.contains("The agent runs this in the seat's own terminal"),
3363 "the template's header tells the same story as the skill",
3364 );
3365 }
3366
3367 #[test]
3371 fn the_shipped_skills_assume_no_local_environment() {
3372 for (skill, text) in
3373 [("handoff", SKILL), ("wizard", WIZARD_SKILL), ("wizard template", WIZARD_TEMPLATE)]
3374 {
3375 for (token, why) in [
3376 ("~/.claude", "a path in one machine's home"),
3377 ("superpowers", "a plugin, absent on a fresh install"),
3378 ("pr-review-loop", "a user-scope skill, absent on a fresh install"),
3379 ("dev-stack", "a project skill, absent on a fresh install"),
3380 ("commit-commands", "a plugin, absent on a fresh install"),
3381 ("`/audit-claude`", "a user-scope command, absent on a fresh install"),
3382 ("unslop", "a user-scope skill, absent on a fresh install"),
3383 ] {
3384 assert!(!text.contains(token), "{skill} names '{token}' ({why})");
3385 }
3386 }
3387 }
3388
3389 #[tokio::test]
3393 async fn the_ground_truth_drops_the_memory_edits_from_the_files_row() {
3394 let dir = tempfile::tempdir().expect("tempdir");
3395 let config = dir.path();
3396 let project = dir.path().join("project");
3397 std::fs::create_dir_all(&project).expect("project dir");
3398 let cwd = std::fs::canonicalize(&project).expect("canonical");
3399 let session = "9cfe315c-aaaa-bbbb-cccc-dddddddddddd";
3400 let key = forge_agent::userdata::catalog::scan::project_key_for_directory(Some(
3401 &cwd.to_string_lossy(),
3402 ));
3403 let transcript =
3404 forge_sdk::projects_dir_for(config).join(&key).join(format!("{session}.jsonl"));
3405 std::fs::create_dir_all(transcript.parent().expect("parent")).expect("projects dir");
3406 let memory = config.join("projects").join(&key).join("memory").join("note.md");
3407 let rows = [
3408 serde_json::json!({
3409 "type": "user",
3410 "message": {"role": "user", "content": [{"type": "text", "text": "do the thing"}]},
3411 "session_id": session,
3412 }),
3413 serde_json::json!({
3414 "type": "assistant",
3415 "session_id": session,
3416 "message": {
3417 "id": "m1",
3418 "role": "assistant",
3419 "model": "a-model",
3420 "content": [{"type": "tool_use", "id": "t1", "name": "Edit",
3421 "input": {"file_path": memory.to_string_lossy()}}],
3422 },
3423 }),
3424 serde_json::json!({
3425 "type": "assistant",
3426 "session_id": session,
3427 "message": {
3428 "id": "m2",
3429 "role": "assistant",
3430 "model": "a-model",
3431 "content": [{"type": "tool_use", "id": "t2", "name": "Edit",
3432 "input": {"file_path": "src/lib.rs"}}],
3433 },
3434 }),
3435 ];
3436 let body = rows.iter().fold(String::new(), |mut body, row| {
3437 body.push_str(&row.to_string());
3438 body.push('\n');
3439 body
3440 });
3441 std::fs::write(&transcript, body).expect("write transcript");
3442
3443 let seat = HandoffSeat {
3444 slot: &SessionSlot::worker("Busytools", "forge", "handoff-cut"),
3445 from: session,
3446 cwd: &cwd,
3447 transcript_path: &transcript,
3448 role: Role::Worker { charter_opening: None },
3449 };
3450 let ground = ground_truth(config, &seat, &[], Vec::new()).await;
3451 let files = ground.sections.iter().find(|section| section.label == "files").expect("files");
3452 let changed =
3453 files.lines.iter().find(|line| line.starts_with("changed:")).expect("changed");
3454 assert!(changed.contains("src/lib.rs"), "the project edit is named: {changed}");
3455 assert!(!changed.contains("memory"), "and the memory edit is not: {changed}");
3456 }
3457
3458 #[test]
3462 fn a_headings_only_reply_is_not_a_note() {
3463 assert!(!parses_as_note("## Where things stand\n## Next"));
3464 assert!(parses_as_note("## Next\nfix the parser."));
3465 }
3466
3467 #[test]
3470 fn a_compliant_note_fits_under_the_cap() {
3471 let words = |n: usize, tag: &str| vec![tag; n].join(" ");
3475 let note = format!("{}\n{}", words(200, "morning"), words(200, "abandon"));
3476 assert!(
3477 note.split_whitespace().count() >= 400,
3478 "the fixture is at least the length the prompt asks for",
3479 );
3480 assert!(
3481 note.chars().count() > 3_000,
3482 "and long enough that a materially lower ceiling would cut it: {} chars",
3483 note.chars().count(),
3484 );
3485 let sections = parse_judgment(¬e);
3486 assert_eq!(sections.len(), 1, "a heading-less note parses whole");
3487 assert_eq!(sections[0].label, "note", "as its one note section");
3488 assert!(
3489 sections
3490 .iter()
3491 .flat_map(|section| §ion.lines)
3492 .all(|line| !line.contains("in the transcript named above")),
3493 "and the cap does not cut it",
3494 );
3495 }
3496
3497 #[test]
3500 fn the_memory_roots_cover_the_project_and_the_environment() {
3501 let roots = memory_roots(Path::new("/tmp/cfg"), Path::new("/tmp/proj"));
3502 assert!(
3503 roots.contains(&PathBuf::from("/tmp/cfg/projects/-tmp-proj/memory")),
3504 "the project's memory dir exactly, not its key dir: {roots:?}",
3505 );
3506 assert!(
3507 roots.contains(&PathBuf::from("/tmp/cfg/memory")),
3508 "and the environment's, where the cross-project notes live: {roots:?}",
3509 );
3510 }
3511
3512 #[test]
3517 fn the_memory_path_match_covers_spellings_and_stops_at_the_boundary() {
3518 let dir = tempfile::tempdir().expect("tempdir");
3519 let root = dir.path().join("memory");
3520 std::fs::create_dir_all(&root).expect("dir");
3521 let roots = [root.clone()];
3522 assert!(
3523 is_memory_path(&root.join("note.md").to_string_lossy(), &roots),
3524 "the plain spelling",
3525 );
3526 let canonical = std::fs::canonicalize(&root).expect("canonical");
3527 assert!(
3528 is_memory_path(&canonical.join("note.md").to_string_lossy(), &roots),
3529 "a canonical spelling of a root reached through a symlink",
3530 );
3531 let home = dirs::home_dir().expect("home");
3532 assert!(
3533 is_memory_path("~/.claude-memory-test/note.md", &[home.join(".claude-memory-test")]),
3534 "a home-relative spelling",
3535 );
3536 assert!(
3537 !is_memory_path(&dir.path().join("memory-not/x.md").to_string_lossy(), &roots),
3538 "a sibling that merely shares the prefix is not inside",
3539 );
3540 }
3541
3542 #[test]
3545 fn the_memory_the_handoff_writes_is_not_a_project_change() {
3546 let roots =
3547 [PathBuf::from("/tmp/cfg/projects/-tmp-proj/memory"), PathBuf::from("/tmp/cfg/memory")];
3548 let messages = vec![
3549 call(
3550 "m1",
3551 "Edit",
3552 serde_json::json!({ "file_path": "/tmp/cfg/projects/-tmp-proj/memory/note.md" }),
3553 ),
3554 call("m2", "Edit", serde_json::json!({ "file_path": "src/handoff.rs" })),
3555 call(
3556 "m3",
3557 "Read",
3558 serde_json::json!({ "file_path": "/tmp/cfg/projects/-tmp-proj/memory/index.md" }),
3559 ),
3560 call("m4", "Read", serde_json::json!({ "file_path": "src/lib.rs" })),
3561 call(
3562 "m5",
3563 "Edit",
3564 serde_json::json!({ "file_path": "/tmp/cfg/memory/til/a-lesson.md" }),
3565 ),
3566 ];
3567 let section = files_section(&messages, &HandoffGitState::default(), &roots);
3568 assert!(
3569 section.lines.contains(&"changed: src/handoff.rs".to_owned()),
3570 "only the project edit is a change: {:?}",
3571 section.lines,
3572 );
3573 assert!(
3574 section.lines.contains(&"read: src/lib.rs".to_owned()),
3575 "and only the project read is a read: {:?}",
3576 section.lines,
3577 );
3578 }
3579
3580 #[test]
3583 fn the_memory_the_handoff_reads_is_not_a_predecessor_read() {
3584 let roots =
3585 [PathBuf::from("/tmp/cfg/projects/-tmp-proj/memory"), PathBuf::from("/tmp/cfg/memory")];
3586 let messages = vec![
3587 call(
3588 "r1",
3589 "Read",
3590 serde_json::json!({ "file_path": "/tmp/cfg/projects/-tmp-proj/memory/index.md" }),
3591 ),
3592 call("r2", "Read", serde_json::json!({ "file_path": "src/lib.rs" })),
3593 call(
3594 "r3",
3595 "Read",
3596 serde_json::json!({ "file_path": "/tmp/cfg/memory/til/a-lesson.md" }),
3597 ),
3598 ];
3599 assert_eq!(read_set(&messages, &roots), vec!["src/lib.rs".to_owned()]);
3600 }
3601}