1use crate::verification::{VerificationReport, VerificationStatus};
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11pub const MUTATION_OBSERVATION_SCHEMA: &str = "a3s.code.mutation-observation.v1";
12const MUTATING_FILE_TOOLS: &[&str] = &["write", "edit", "patch", "download"];
13
14#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(tag = "kind", rename_all = "snake_case")]
17pub enum CompletionTerminal {
18 #[default]
20 Narrative,
21 Verified { effect_digest: String },
23 Waived { effect_digest: String },
25 Distinct,
28}
29
30impl CompletionTerminal {
31 pub fn is_narrative(&self) -> bool {
32 matches!(self, Self::Narrative)
33 }
34}
35
36pub fn fold_step_completions(terminals: &[CompletionTerminal]) -> CompletionTerminal {
39 let mut bound = Vec::new();
40 for terminal in terminals {
41 if terminal.is_narrative() || bound.contains(terminal) {
42 continue;
43 }
44 bound.push(terminal.clone());
45 }
46 match bound.as_slice() {
47 [] => CompletionTerminal::Narrative,
48 [one] => one.clone(),
49 many => {
50 if let Some(digest) = completion_digest(&many[0]) {
51 if many
52 .iter()
53 .all(|terminal| completion_digest(terminal) == Some(digest))
54 {
55 if many
56 .iter()
57 .any(|terminal| matches!(terminal, CompletionTerminal::Verified { .. }))
58 {
59 return CompletionTerminal::Verified {
60 effect_digest: digest.to_string(),
61 };
62 }
63 return many[0].clone();
64 }
65 }
66 CompletionTerminal::Distinct
67 }
68 }
69}
70
71fn completion_digest(terminal: &CompletionTerminal) -> Option<&str> {
72 match terminal {
73 CompletionTerminal::Narrative | CompletionTerminal::Distinct => None,
74 CompletionTerminal::Verified { effect_digest }
75 | CompletionTerminal::Waived { effect_digest } => Some(effect_digest.as_str()),
76 }
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct CompletionWaiverV1 {
82 pub effect_digest: String,
83 pub reason: String,
84}
85
86impl CompletionWaiverV1 {
87 pub fn new(effect_digest: impl Into<String>, reason: impl Into<String>) -> Option<Self> {
88 let effect_digest = effect_digest.into();
89 let reason = reason.into();
90 if effect_digest.trim().is_empty() || reason.trim().is_empty() {
91 return None;
92 }
93 Some(Self {
94 effect_digest,
95 reason,
96 })
97 }
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
102pub struct PlanRunAdmission {
103 pub claims_implementation: bool,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub plan_digest: Option<String>,
106}
107
108impl PlanRunAdmission {
109 pub fn ordinary() -> Self {
110 Self::default()
111 }
112
113 pub fn implementation(plan_digest: impl Into<String>) -> Self {
114 Self {
115 claims_implementation: true,
116 plan_digest: Some(plan_digest.into()),
117 }
118 }
119
120 pub fn label(&self) -> &'static str {
122 match (
123 self.claims_implementation,
124 self.plan_digest.as_deref().map(str::trim),
125 ) {
126 (true, Some(digest)) if !digest.is_empty() => "plan_implementation",
127 _ => "ordinary",
128 }
129 }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133pub struct MutationRecord {
134 pub tool: String,
135 pub path: String,
136 pub content_digest: String,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142struct OpenWorkspaceChild {
143 task_id: String,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
145 porcelain: Option<Vec<String>>,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 head: Option<String>,
148 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
149 nongit: bool,
150}
151
152#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
153pub struct MutationLedger {
154 records: Vec<MutationRecord>,
155 #[serde(default)]
156 digest: String,
157 #[serde(default, skip_serializing_if = "Vec::is_empty")]
159 open_children: Vec<OpenWorkspaceChild>,
160 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
163 observation_incomplete: bool,
164}
165
166impl MutationLedger {
167 pub fn is_empty(&self) -> bool {
168 self.records.is_empty()
169 }
170
171 pub fn digest(&self) -> &str {
172 &self.digest
173 }
174
175 pub fn paths(&self) -> impl Iterator<Item = &str> {
176 self.records.iter().map(|record| record.path.as_str())
177 }
178
179 pub fn content_digest_for_path(&self, path: &str) -> Option<&str> {
181 self.records.iter().rev().find_map(|record| {
182 if crate::verification::mutation_path_matches(record.path.as_str(), path) {
183 Some(record.content_digest.as_str())
184 } else {
185 None
186 }
187 })
188 }
189
190 pub fn has_content_digest(&self, path: &str) -> bool {
196 self.records.iter().any(|record| {
197 !record.content_digest.is_empty()
198 && crate::verification::mutation_path_matches(record.path.as_str(), path)
199 })
200 }
201
202 pub fn content_digest_matches(&self, path: &str, digest: &str) -> bool {
204 let digest = digest.trim();
205 if digest.is_empty() {
206 return false;
207 }
208 self.records.iter().any(|record| {
209 record.content_digest == digest
210 && crate::verification::mutation_path_matches(record.path.as_str(), path)
211 })
212 }
213
214 pub fn observe_tool(&mut self, tool: &str, exit_code: i32, metadata: Option<&Value>) {
219 let Some(metadata) = metadata else {
220 return;
221 };
222 let tool_key = tool.to_ascii_lowercase();
223 if metadata.get("changed_paths").is_some() {
224 record_changed_paths(self, &tool_key, metadata);
225 }
226 if let Some(task_id) = metadata.get("workspace_child").and_then(Value::as_str) {
227 self.observe_workspace_child(task_id);
228 }
229 if exit_code != 0 {
230 record_nested_tool_effects(self, metadata);
231 return;
232 }
233 if MUTATING_FILE_TOOLS.contains(&tool_key.as_str()) {
234 if let Some(path) = metadata.get("file_path").and_then(Value::as_str) {
235 self.push(tool_key, path, content_digest(metadata));
236 }
237 return;
238 }
239 record_nested_tool_effects(self, metadata);
240 }
241
242 fn observe_workspace_child(&mut self, task_id: &str) {
243 let task_id = task_id.trim();
244 if task_id.is_empty() {
245 return;
246 }
247 if let Some(paths) = crate::porcelain::take_settled_workspace_child(task_id) {
248 self.record_child_paths(task_id, &paths);
249 return;
250 }
251 let Some(marker) = crate::porcelain::child_marker(task_id) else {
252 return;
253 };
254 if self
255 .open_children
256 .iter()
257 .any(|child| child.task_id == marker.task_id)
258 {
259 return;
260 }
261 self.open_children.push(OpenWorkspaceChild {
262 task_id: marker.task_id,
263 porcelain: marker.porcelain,
264 head: marker.head,
265 nongit: marker.nongit,
266 });
267 }
268
269 pub fn has_open_children(&self) -> bool {
270 !self.open_children.is_empty()
271 }
272
273 pub fn observation_incomplete(&self) -> bool {
274 self.observation_incomplete
275 }
276
277 pub fn set_observation_incomplete(&mut self, incomplete: bool) {
280 self.observation_incomplete = incomplete;
281 }
282
283 pub fn observe_unseen_paths(&mut self, paths: &[String]) {
286 let seen = self
287 .records
288 .iter()
289 .map(|record| record.path.clone())
290 .collect::<std::collections::HashSet<_>>();
291 let accepted: Vec<String> = paths
292 .iter()
293 .map(|path| path.trim().trim_start_matches("./"))
294 .filter(|path| !path.is_empty() && !seen.contains(*path))
295 .filter(|path| {
296 *path != ".a3s"
297 && !path.starts_with(".a3s/")
298 && *path != ".git"
299 && !path.starts_with(".git/")
300 })
301 .map(str::to_string)
302 .collect();
303 if accepted.is_empty() {
304 return;
305 }
306 for path in accepted {
307 self.records.push(MutationRecord {
308 tool: "workspace".to_string(),
309 path,
310 content_digest: String::new(),
311 });
312 }
313 self.rehash();
314 }
315
316 fn record_child_paths(&mut self, task_id: &str, paths: &[String]) {
317 self.open_children.retain(|child| child.task_id != task_id);
318 for path in paths {
319 self.push("task".to_string(), path, String::new());
320 }
321 }
322
323 fn push(&mut self, tool: String, path: &str, content_digest: String) {
324 if crate::porcelain::is_harness_path(path) {
325 return;
326 }
327 let path = path.trim();
328 if path.is_empty() {
329 return;
330 }
331 self.records.push(MutationRecord {
332 tool,
333 path: path.to_string(),
334 content_digest,
335 });
336 self.rehash();
337 }
338
339 fn rehash(&mut self) {
340 self.records
341 .sort_by(|left, right| left.path.cmp(&right.path).then(left.tool.cmp(&right.tool)));
342 self.digest = effect_digest(&self.records);
343 }
344}
345
346pub async fn absorb_open_workspace_children(
350 ledger: &mut MutationLedger,
351 workspace: &std::path::Path,
352 cancel: &tokio_util::sync::CancellationToken,
353) -> bool {
354 let pending = ledger.open_children.to_vec();
355 let mut incomplete = false;
356 for child in pending {
357 if let Some(paths) = crate::porcelain::take_settled_workspace_child(&child.task_id) {
358 ledger.record_child_paths(&child.task_id, &paths);
359 continue;
360 }
361 if crate::porcelain::workspace_child_pending(&child.task_id) {
362 if let Some(paths) =
363 crate::porcelain::await_workspace_child(&child.task_id, workspace, cancel).await
364 {
365 ledger.record_child_paths(&child.task_id, &paths);
366 }
367 continue;
368 }
369 if child.nongit && child.porcelain.is_none() && child.head.is_none() {
370 continue;
371 }
372 let observed = crate::porcelain::delta(
373 workspace,
374 crate::porcelain::snapshot_from_parts(
375 child.porcelain.clone(),
376 child.head.clone(),
377 Vec::new(),
378 ),
379 )
380 .await;
381 if observed.incomplete {
382 incomplete = true;
383 }
384 ledger.record_child_paths(&child.task_id, &observed.paths);
385 }
386 incomplete
387}
388
389fn record_changed_paths(ledger: &mut MutationLedger, tool: &str, metadata: &Value) {
390 let Some(paths) = metadata.get("changed_paths").and_then(Value::as_array) else {
391 return;
392 };
393 for path in paths {
394 if let Some(path) = path.as_str() {
395 ledger.push(tool.to_string(), path, content_digest(metadata));
396 }
397 }
398}
399
400fn record_nested_tool_effects(ledger: &mut MutationLedger, metadata: &Value) {
401 for (name, nested) in nested_tool_calls(metadata) {
402 ledger.observe_tool(name, 0, nested);
403 }
404}
405
406pub(crate) fn nested_tool_calls(metadata: &Value) -> Vec<(&str, Option<&Value>)> {
409 let mut calls = Vec::new();
410 push_nested_calls(
411 &mut calls,
412 metadata.pointer("/program/tool_calls"),
413 "tool_name",
414 );
415 push_nested_calls(&mut calls, metadata.get("results"), "tool");
416 calls
417}
418
419fn push_nested_calls<'a>(
420 out: &mut Vec<(&'a str, Option<&'a Value>)>,
421 calls: Option<&'a Value>,
422 name_key: &str,
423) {
424 let Some(calls) = calls.and_then(Value::as_array) else {
425 return;
426 };
427 for call in calls {
428 if call.get("success").and_then(Value::as_bool) != Some(true) {
429 continue;
430 }
431 if call.get("exit_code").and_then(Value::as_i64) != Some(0) {
432 continue;
433 }
434 let Some(name) = call.get(name_key).and_then(Value::as_str) else {
435 continue;
436 };
437 if name.is_empty() {
438 continue;
439 }
440 out.push((name, call.get("metadata")));
441 }
442}
443
444fn content_digest(metadata: &Value) -> String {
445 if let Some(after) = metadata.get("after").and_then(Value::as_str) {
446 return sha256::digest(after.as_bytes());
447 }
448 sha256::digest(metadata.to_string().as_bytes())
449}
450
451fn effect_digest(records: &[MutationRecord]) -> String {
452 let canonical = records
453 .iter()
454 .map(|record| format!("{}|{}|{}", record.tool, record.path, record.content_digest))
455 .collect::<Vec<_>>()
456 .join("\n");
457 sha256::digest(canonical.as_bytes())
458}
459
460#[derive(Debug, Clone, PartialEq, Eq)]
461pub enum CompletionGate {
462 Allow(CompletionTerminal),
463 Continue {
465 message: String,
466 },
467 Incomplete {
469 message: String,
470 },
471}
472
473pub fn decide_completion(
474 ledger: &MutationLedger,
475 reports: &[VerificationReport],
476 waivers: &[CompletionWaiverV1],
477 allow_continuation: bool,
478) -> CompletionGate {
479 decide_with_observations(ledger, reports, waivers, allow_continuation, &[])
480}
481
482pub fn decide_with_observations(
483 ledger: &MutationLedger,
484 reports: &[VerificationReport],
485 waivers: &[CompletionWaiverV1],
486 allow_continuation: bool,
487 observations: &[crate::external_observation::ExternalObservationV1],
488) -> CompletionGate {
489 let open = crate::external_observation::still_open(observations, waivers, ledger.digest());
490 if crate::external_observation::blocks_success(&open) {
491 let digest = open
492 .iter()
493 .map(|observation| observation.digest.as_str())
494 .collect::<Vec<_>>()
495 .join(",");
496 let message = format!(
497 "completion gate: external observation {digest} still requires a workspace change. A final answer does not clear it. Bind a newer observation of the same subject or a host waiver for the observation digest."
498 );
499 return if allow_continuation {
500 CompletionGate::Continue { message }
501 } else {
502 CompletionGate::Incomplete { message }
503 };
504 }
505 if ledger.has_open_children() {
506 let ids = ledger
507 .open_children
508 .iter()
509 .map(|child| child.task_id.as_str())
510 .collect::<Vec<_>>()
511 .join(",");
512 let message = format!(
513 "completion gate: background workspace task {ids} has not been observed. A final answer does not observe its writes."
514 );
515 return CompletionGate::Incomplete { message };
516 }
517 if ledger.observation_incomplete() {
518 return CompletionGate::Incomplete {
519 message: "completion gate: workspace observation is incomplete, so this digest is not the effect. A final answer, a waiver, or a verification of a partial list does not close it.".to_string(),
520 };
521 }
522 if ledger.is_empty() {
523 return CompletionGate::Allow(CompletionTerminal::Narrative);
524 }
525 let digest = ledger.digest().to_string();
526 if waivers.iter().any(|waiver| waiver.effect_digest == digest) {
527 return CompletionGate::Allow(CompletionTerminal::Waived {
528 effect_digest: digest,
529 });
530 }
531 if reports
532 .iter()
533 .any(|report| report_binds_pass(report, &digest))
534 {
535 return CompletionGate::Allow(CompletionTerminal::Verified {
536 effect_digest: digest,
537 });
538 }
539 let message = format!(
540 "completion gate: workspace mutation {digest} has no bound Passed verification and no host waiver. Assistant text does not count. Bind a verification_report.effect_digest to this digest with required checks Passed, or obtain a host waiver for this digest."
541 );
542 CompletionGate::Incomplete { message }
550}
551
552fn report_binds_pass(report: &VerificationReport, digest: &str) -> bool {
553 if report.effect_digest.as_deref() != Some(digest) {
554 return false;
555 }
556 let required: Vec<_> = report
557 .checks
558 .iter()
559 .filter(|check| check.required)
560 .collect();
561 if required.is_empty() {
562 return false;
563 }
564 required
565 .iter()
566 .all(|check| check.status == VerificationStatus::Passed)
567 && !matches!(
568 report.status,
569 VerificationStatus::Failed | VerificationStatus::NeedsReview
570 )
571}
572
573#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
575pub struct MutationObservationV1 {
576 pub schema: String,
577 pub path: String,
578 pub status: String,
579 #[serde(default, skip_serializing_if = "Option::is_none")]
580 pub workspace_revision: Option<u64>,
581 #[serde(default, skip_serializing_if = "Vec::is_empty")]
582 pub items: Vec<String>,
583}
584
585impl MutationObservationV1 {
586 pub fn unavailable(path: impl Into<String>) -> Self {
587 Self {
588 schema: MUTATION_OBSERVATION_SCHEMA.to_string(),
589 path: path.into(),
590 status: "unavailable".to_string(),
591 workspace_revision: None,
592 items: Vec::new(),
593 }
594 }
595
596 pub fn stale(path: impl Into<String>, workspace_revision: Option<u64>) -> Self {
597 Self {
598 schema: MUTATION_OBSERVATION_SCHEMA.to_string(),
599 path: path.into(),
600 status: "stale".to_string(),
601 workspace_revision,
602 items: Vec::new(),
603 }
604 }
605
606 pub fn diagnostics(
607 path: impl Into<String>,
608 workspace_revision: Option<u64>,
609 items: Vec<String>,
610 ) -> Self {
611 Self {
612 schema: MUTATION_OBSERVATION_SCHEMA.to_string(),
613 path: path.into(),
614 status: "diagnostics".to_string(),
615 workspace_revision,
616 items,
617 }
618 }
619
620 pub fn render(&self) -> String {
621 let items = if self.items.is_empty() {
622 String::new()
623 } else {
624 format!(" items={}", self.items.join(" | "))
625 };
626 format!(
627 "[mutation observation] path={} status={}{items}",
628 self.path, self.status
629 )
630 }
631}
632
633pub fn observation_from_diagnostics(
636 path: &str,
637 revision: Option<u64>,
638 stale: bool,
639 items: Vec<String>,
640) -> MutationObservationV1 {
641 if stale {
642 return MutationObservationV1::stale(path, revision);
643 }
644 MutationObservationV1::diagnostics(path, revision, items)
645}
646
647pub fn model_visible_observation(output: &str, observation: &MutationObservationV1) -> String {
650 let rendered = observation.render();
651 if output.contains("[mutation observation]") {
652 output.to_string()
653 } else if output.is_empty() {
654 rendered
655 } else {
656 format!("{output}\n{rendered}")
657 }
658}
659
660pub fn attach_observation(metadata: &mut Option<Value>, observation: &MutationObservationV1) {
661 let value = serde_json::to_value(observation).unwrap_or(Value::Null);
662 match metadata {
663 Some(Value::Object(map)) => {
664 map.insert("mutation_observation".to_string(), value);
665 }
666 Some(other) => {
667 *metadata = Some(serde_json::json!({
668 "previous": other,
669 "mutation_observation": value,
670 }));
671 }
672 None => {
673 *metadata = Some(serde_json::json!({ "mutation_observation": value }));
674 }
675 }
676}
677
678#[cfg(test)]
679mod tests {
680 use super::*;
681 use crate::verification::VerificationCheck;
682
683 fn ledger_with_write() -> MutationLedger {
684 let mut ledger = MutationLedger::default();
685 ledger.observe_tool(
686 "write",
687 0,
688 Some(&serde_json::json!({"file_path": "src/lib.rs", "after": "fn main() {}"})),
689 );
690 ledger
691 }
692
693 #[test]
694 fn read_only_tool_does_not_open_the_gate() {
695 let mut ledger = MutationLedger::default();
696 ledger.observe_tool(
697 "read",
698 0,
699 Some(&serde_json::json!({"file_path": "src/lib.rs"})),
700 );
701 assert!(ledger.is_empty());
702 assert!(matches!(
703 decide_completion(&ledger, &[], &[], false),
704 CompletionGate::Allow(CompletionTerminal::Narrative)
705 ));
706 }
707
708 #[test]
709 fn assistant_prose_does_not_satisfy_a_mutation() {
710 let ledger = ledger_with_write();
711 let decision = decide_completion(&ledger, &[], &[], false);
712 match decision {
713 CompletionGate::Incomplete { message } => {
714 assert!(message.starts_with("completion gate:"));
715 assert!(!message.contains("tests passed"));
716 }
717 other => panic!("expected incomplete, got {other:?}"),
718 }
719 }
720
721 #[test]
722 fn open_external_observation_still_continues_once() {
723 let observation = crate::external_observation::ExternalObservationV1::new(
724 "review",
725 "src/lib.rs",
726 "obs-digest",
727 "needs a workspace change",
728 crate::external_observation::RequiredAction::WorkspaceChange,
729 )
730 .expect("observation");
731 match decide_with_observations(
732 &MutationLedger::default(),
733 &[],
734 &[],
735 true,
736 &[observation.clone()],
737 ) {
738 CompletionGate::Continue { message } => {
739 assert!(message.starts_with("completion gate:"));
740 assert!(message.contains("external observation"));
741 }
742 other => panic!("expected continue, got {other:?}"),
743 }
744 match decide_with_observations(&MutationLedger::default(), &[], &[], false, &[observation])
745 {
746 CompletionGate::Incomplete { message } => {
747 assert!(message.starts_with("completion gate:"));
748 assert!(message.contains("external observation"));
749 }
750 other => panic!("expected incomplete without continuation, got {other:?}"),
751 }
752 }
753
754 #[test]
755 fn unbound_mutation_does_not_spend_a_continuation_the_model_cannot_close() {
756 let ledger = ledger_with_write();
757 match decide_completion(&ledger, &[], &[], true) {
758 CompletionGate::Incomplete { message } => {
759 assert!(message.starts_with("completion gate:"));
760 assert!(message.contains("Assistant text does not count"));
761 }
762 other => panic!("expected incomplete, got {other:?}"),
763 }
764 }
765
766 #[test]
767 fn bound_passed_report_allows_verified_terminal() {
768 let ledger = ledger_with_write();
769 let report = VerificationReport::new(
770 "edit",
771 vec![VerificationCheck::required("build", "command", "compiles")
772 .with_status(VerificationStatus::Passed)],
773 )
774 .with_effect_digest(ledger.digest());
775 match decide_completion(&ledger, &[report], &[], false) {
776 CompletionGate::Allow(CompletionTerminal::Verified { effect_digest }) => {
777 assert_eq!(effect_digest, ledger.digest());
778 }
779 other => panic!("expected verified, got {other:?}"),
780 }
781 }
782
783 #[test]
784 fn empty_required_checks_are_not_a_pass() {
785 let ledger = ledger_with_write();
786 let report = VerificationReport::new("edit", vec![]).with_effect_digest(ledger.digest());
787 assert!(matches!(
788 decide_completion(&ledger, &[report], &[], false),
789 CompletionGate::Incomplete { .. }
790 ));
791 }
792
793 #[test]
794 fn incomplete_observation_is_not_closed_by_a_waiver_of_a_partial_digest() {
795 let mut ledger = ledger_with_write();
796 ledger.set_observation_incomplete(true);
797 let waiver = CompletionWaiverV1::new(ledger.digest(), "user accepted residual risk")
798 .expect("waiver");
799 match decide_completion(&ledger, &[], &[waiver], false) {
800 CompletionGate::Incomplete { message } => {
801 assert!(message.contains("observation is incomplete"));
802 assert!(!message.contains("tests passed"));
803 }
804 other => panic!("expected incomplete, got {other:?}"),
805 }
806 let mut empty = MutationLedger::default();
807 empty.set_observation_incomplete(true);
808 assert!(matches!(
809 decide_completion(&empty, &[], &[], false),
810 CompletionGate::Incomplete { .. }
811 ));
812 }
813
814 #[test]
815 fn waiver_is_distinct_and_does_not_transfer_to_another_digest() {
816 let ledger = ledger_with_write();
817 let waiver = CompletionWaiverV1::new(ledger.digest(), "user accepted residual risk")
818 .expect("waiver");
819 match decide_completion(&ledger, &[], &[waiver.clone()], false) {
820 CompletionGate::Allow(CompletionTerminal::Waived { effect_digest }) => {
821 assert_eq!(effect_digest, ledger.digest());
822 }
823 other => panic!("expected waiver, got {other:?}"),
824 }
825 let mut other = MutationLedger::default();
826 other.observe_tool(
827 "write",
828 0,
829 Some(&serde_json::json!({"file_path": "other.rs", "after": "different"})),
830 );
831 assert!(matches!(
832 decide_completion(&other, &[], &[waiver], false),
833 CompletionGate::Incomplete { .. }
834 ));
835 }
836
837 #[test]
838 fn diagnostic_appears_on_the_next_model_visible_tool_result() {
839 let observation = observation_from_diagnostics(
840 "src/lib.rs",
841 Some(4),
842 false,
843 vec!["src/lib.rs:3: unused variable".to_string()],
844 );
845 let visible = model_visible_observation("wrote src/lib.rs", &observation);
846 assert!(visible.contains("unused variable"));
847 assert!(!visible.contains("code_diagnostics"));
848 let message = crate::llm::Message::tool_result("write-1", &visible, false);
849 let model_input = message
850 .content
851 .iter()
852 .find_map(|block| match block {
853 crate::llm::ContentBlock::ToolResult {
854 content: crate::llm::ToolResultContentField::Text(text),
855 ..
856 } => Some(text.as_str()),
857 _ => None,
858 })
859 .expect("tool result is the next model input");
860 assert!(model_input.contains("unused variable"));
861 let stale = observation_from_diagnostics(
862 "src/lib.rs",
863 Some(3),
864 true,
865 vec!["src/lib.rs:1: previous revision".to_string()],
866 );
867 assert!(!stale.render().contains("previous revision"));
868 assert!(stale.render().contains("stale"));
869 }
870
871 #[test]
872 fn unavailable_observation_does_not_satisfy_the_gate() {
873 let ledger = ledger_with_write();
874 let observation = MutationObservationV1::unavailable("src/lib.rs");
875 assert!(observation.render().contains("unavailable"));
876 assert!(matches!(
877 decide_completion(&ledger, &[], &[], false),
878 CompletionGate::Incomplete { .. }
879 ));
880 }
881
882 #[test]
883 fn unseen_workspace_path_opens_the_gate_without_a_second_digest() {
884 let mut ledger = MutationLedger::default();
885 ledger.observe_tool(
886 "write",
887 0,
888 Some(&serde_json::json!({ "file_path": "src/lib.rs" })),
889 );
890 let digest = ledger.digest().to_string();
891 ledger.observe_unseen_paths(&[
892 "src/lib.rs".to_string(),
893 ".a3s/tui/outcomes/v1/id.json".to_string(),
894 "guest.txt".to_string(),
895 ]);
896 assert_ne!(ledger.digest(), digest);
897 assert!(ledger.paths().any(|path| path == "guest.txt"));
898 assert!(!ledger.paths().any(|path| path.contains(".a3s")));
899 assert_eq!(
900 ledger.paths().filter(|path| *path == "src/lib.rs").count(),
901 1,
902 "an already recorded path must not mint a second effect identity"
903 );
904 assert!(matches!(
905 decide_completion(&ledger, &[], &[], false),
906 CompletionGate::Incomplete { .. }
907 ));
908 }
909
910 #[tokio::test]
911 async fn background_workspace_child_blocks_narrative_until_its_delta_is_observed() {
912 let workspace = tempfile::tempdir().unwrap();
913 let task_id = "task-open-child";
914 crate::porcelain::reserve_workspace_child(task_id);
915 crate::porcelain::begin_workspace_child(task_id, workspace.path()).await;
916 let mut ledger = MutationLedger::default();
917 ledger.observe_tool(
918 "task",
919 0,
920 Some(&serde_json::json!({ "workspace_child": task_id })),
921 );
922 match decide_completion(&ledger, &[], &[], true) {
923 CompletionGate::Incomplete { message } => {
924 assert!(message.contains("background workspace task"));
925 assert!(message.contains(task_id));
926 }
927 other => panic!("expected incomplete, got {other:?}"),
928 }
929
930 std::fs::write(workspace.path().join("guest.txt"), "hello\n").unwrap();
931 crate::porcelain::settle_workspace_child(task_id, workspace.path()).await;
932 absorb_open_workspace_children(
933 &mut ledger,
934 workspace.path(),
935 &tokio_util::sync::CancellationToken::new(),
936 )
937 .await;
938 assert!(
939 ledger.paths().any(|path| path == "guest.txt"),
940 "settled background write was not a parent mutation"
941 );
942 assert!(matches!(
943 decide_completion(&ledger, &[], &[], false),
944 CompletionGate::Incomplete { .. }
945 ));
946 }
947
948 #[test]
949 fn failed_wrapper_with_changed_paths_opens_the_gate() {
950 let mut ledger = MutationLedger::default();
951 ledger.observe_tool(
952 "bash",
953 1,
954 Some(&serde_json::json!({
955 "exit_code": 1,
956 "changed_paths": ["guest.txt"]
957 })),
958 );
959 match decide_completion(&ledger, &[], &[], false) {
960 CompletionGate::Incomplete { message } => {
961 assert!(message.starts_with("completion gate:"));
962 }
963 other => panic!("expected incomplete, got {other:?}"),
964 }
965 }
966
967 #[test]
968 fn failed_write_path_without_changed_paths_is_not_a_mutation() {
969 let mut ledger = MutationLedger::default();
970 ledger.observe_tool(
971 "write",
972 1,
973 Some(&serde_json::json!({"file_path": "guest.txt"})),
974 );
975 assert!(ledger.is_empty());
976 }
977
978 #[test]
979 fn bash_without_changed_paths_is_not_a_mutation() {
980 let mut ledger = MutationLedger::default();
981 ledger.observe_tool(
982 "bash",
983 0,
984 Some(&serde_json::json!({"command": "rm -rf src"})),
985 );
986 assert!(ledger.is_empty());
987 }
988
989 #[test]
990 fn stale_observation_omits_items() {
991 let observation = MutationObservationV1::stale("src/lib.rs", Some(4));
992 assert!(observation.items.is_empty());
993 assert_eq!(observation.status, "stale");
994 assert!(!observation.render().contains("error"));
995 }
996
997 #[test]
998 fn nested_batch_write_opens_the_gate() {
999 let mut ledger = MutationLedger::default();
1000 ledger.observe_tool(
1001 "batch",
1002 0,
1003 Some(&serde_json::json!({
1004 "status": "complete",
1005 "results": [{
1006 "tool": "write",
1007 "success": true,
1008 "exit_code": 0,
1009 "metadata": {"file_path": "src/lib.rs", "after": "fn main() {}"}
1010 }, {
1011 "title": "not a tool call",
1012 "success": true
1013 }]
1014 })),
1015 );
1016 match decide_completion(&ledger, &[], &[], false) {
1017 CompletionGate::Incomplete { message } => {
1018 assert!(message.starts_with("completion gate:"));
1019 }
1020 other => panic!("expected incomplete, got {other:?}"),
1021 }
1022 }
1023
1024 #[test]
1025 fn retrieval_index_stamp_is_not_a_source_mutation() {
1026 let mut ledger = MutationLedger::default();
1027 ledger.observe_tool(
1028 "search",
1029 0,
1030 Some(&serde_json::json!({
1031 "changed_paths": [".a3s-code/grep-trigram/stamp.txt"]
1032 })),
1033 );
1034 assert!(
1035 matches!(
1036 decide_completion(&ledger, &[], &[], false),
1037 CompletionGate::Allow(CompletionTerminal::Narrative)
1038 ),
1039 "a retrieval index stamp opened the completion gate"
1040 );
1041 }
1042
1043 #[test]
1044 fn skill_changed_paths_open_the_gate() {
1045 let mut ledger = MutationLedger::default();
1046 ledger.observe_tool(
1047 "skill",
1048 0,
1049 Some(&serde_json::json!({
1050 "skill_name": "writer",
1051 "tool_calls": 1,
1052 "changed_paths": ["guest.txt"]
1053 })),
1054 );
1055 match decide_completion(&ledger, &[], &[], false) {
1056 CompletionGate::Incomplete { message } => {
1057 assert!(message.starts_with("completion gate:"));
1058 }
1059 other => panic!("expected incomplete, got {other:?}"),
1060 }
1061 }
1062
1063 #[test]
1064 fn nested_program_write_opens_the_gate() {
1065 let mut ledger = MutationLedger::default();
1066 ledger.observe_tool(
1067 "program",
1068 0,
1069 Some(&serde_json::json!({
1070 "program": {
1071 "tool_calls": [{
1072 "tool_name": "write",
1073 "success": true,
1074 "exit_code": 0,
1075 "metadata": {"file_path": "src/lib.rs", "after": "fn main() {}"}
1076 }]
1077 }
1078 })),
1079 );
1080 match decide_completion(&ledger, &[], &[], false) {
1081 CompletionGate::Incomplete { message } => {
1082 assert!(message.starts_with("completion gate:"));
1083 }
1084 other => panic!("expected incomplete, got {other:?}"),
1085 }
1086 }
1087
1088 #[test]
1089 fn bound_step_closure_is_not_rewritten_as_narrative() {
1090 let verified = CompletionTerminal::Verified {
1091 effect_digest: "digest-a".to_string(),
1092 };
1093 assert_eq!(
1094 fold_step_completions(&[CompletionTerminal::Narrative, verified.clone()]),
1095 verified
1096 );
1097 assert_eq!(
1098 fold_step_completions(&[
1099 verified.clone(),
1100 CompletionTerminal::Waived {
1101 effect_digest: "digest-a".to_string(),
1102 },
1103 ]),
1104 verified
1105 );
1106 assert_eq!(
1107 fold_step_completions(&[
1108 CompletionTerminal::Verified {
1109 effect_digest: "digest-a".to_string(),
1110 },
1111 CompletionTerminal::Verified {
1112 effect_digest: "digest-b".to_string(),
1113 },
1114 ]),
1115 CompletionTerminal::Distinct
1116 );
1117 assert!(CompletionTerminal::Distinct != CompletionTerminal::Narrative);
1118 }
1119
1120 #[test]
1121 fn plan_claim_without_digest_is_ordinary() {
1122 let claimed = PlanRunAdmission {
1123 claims_implementation: true,
1124 plan_digest: None,
1125 };
1126 assert_eq!(claimed.label(), "ordinary");
1127 assert_eq!(
1128 PlanRunAdmission::implementation("abc").label(),
1129 "plan_implementation"
1130 );
1131 }
1132
1133 #[test]
1134 fn fold_same_digest_waivers_keeps_first_bound_terminal() {
1135 let waived = CompletionTerminal::Waived {
1136 effect_digest: "same".into(),
1137 };
1138 assert_eq!(
1139 fold_step_completions(&[waived.clone(), waived.clone()]),
1140 waived
1141 );
1142 assert!(completion_digest(&CompletionTerminal::Distinct).is_none());
1143 assert!(completion_digest(&CompletionTerminal::Narrative).is_none());
1144 }
1145
1146 #[test]
1147 fn fold_same_digest_prefers_verified_over_waived() {
1148 let verified = CompletionTerminal::Verified {
1149 effect_digest: "d".into(),
1150 };
1151 let waived = CompletionTerminal::Waived {
1152 effect_digest: "d".into(),
1153 };
1154 assert_eq!(fold_step_completions(&[waived, verified.clone()]), verified);
1155 }
1156
1157 #[test]
1158 fn fold_different_digests_is_distinct() {
1159 assert_eq!(
1160 fold_step_completions(&[
1161 CompletionTerminal::Verified {
1162 effect_digest: "a".into(),
1163 },
1164 CompletionTerminal::Verified {
1165 effect_digest: "b".into(),
1166 },
1167 ]),
1168 CompletionTerminal::Distinct
1169 );
1170 }
1171
1172 #[test]
1173 fn nested_tool_calls_skip_failed_and_nameless_entries() {
1174 let metadata = serde_json::json!({
1175 "results": [
1176 {"tool": "write", "success": false, "exit_code": 0, "metadata": {"file_path": "a.rs"}},
1177 {"tool": "write", "success": true, "exit_code": 1, "metadata": {"file_path": "b.rs"}},
1178 {"tool": "", "success": true, "exit_code": 0, "metadata": {"file_path": "c.rs"}},
1179 {"success": true, "exit_code": 0, "metadata": {"file_path": "d.rs"}},
1180 {"tool": "write", "success": true, "exit_code": 0, "metadata": {"file_path": "e.rs", "after": "ok"}}
1181 ]
1182 });
1183 let mut ledger = MutationLedger::default();
1184 ledger.observe_tool("task", 1, Some(&metadata));
1185 assert!(
1186 ledger.paths().any(|path| path == "e.rs"),
1187 "only the successful named nested write should land"
1188 );
1189 assert!(!ledger.paths().any(|path| path == "a.rs"));
1190 assert!(!ledger.paths().any(|path| path == "b.rs"));
1191 }
1192
1193 #[test]
1194 fn completion_waiver_rejects_blank_digest_or_reason() {
1195 assert!(CompletionWaiverV1::new(" ", "reason").is_none());
1196 assert!(CompletionWaiverV1::new("digest", " ").is_none());
1197 assert!(CompletionWaiverV1::new("", "reason").is_none());
1198 }
1199
1200 #[test]
1201 fn content_digest_for_path_misses_unrelated_records() {
1202 let ledger = ledger_with_write();
1203 assert!(ledger.content_digest_for_path("other.rs").is_none());
1204 assert!(ledger.content_digest_for_path("src/lib.rs").is_some());
1205 }
1206
1207 #[test]
1208 fn observe_workspace_child_ignores_blank_and_missing_markers() {
1209 let mut ledger = MutationLedger::default();
1210 ledger.observe_tool(
1211 "bash",
1212 0,
1213 Some(&serde_json::json!({"workspace_child": " "})),
1214 );
1215 assert!(!ledger.has_open_children());
1216
1217 let missing = format!("missing-child-{}", std::process::id());
1218 ledger.observe_tool(
1219 "bash",
1220 0,
1221 Some(&serde_json::json!({ "workspace_child": missing })),
1222 );
1223 assert!(!ledger.has_open_children());
1224 }
1225
1226 #[tokio::test]
1227 async fn observe_workspace_child_records_settled_paths_and_dedupes_open_markers() {
1228 let root = tempfile::tempdir().unwrap();
1229 let settled = format!("settled-child-{}", std::process::id());
1230 let watch = crate::porcelain::Watch::start(root.path()).await;
1231 crate::porcelain::install_workspace_child(&settled, watch);
1232 std::fs::write(root.path().join("guest.txt"), "x\n").unwrap();
1233 crate::porcelain::settle_workspace_child(&settled, root.path()).await;
1234
1235 let mut ledger = MutationLedger::default();
1236 ledger.observe_tool(
1237 "bash",
1238 0,
1239 Some(&serde_json::json!({ "workspace_child": settled })),
1240 );
1241 assert!(!ledger.has_open_children());
1242 assert!(!ledger.is_empty());
1243
1244 let open = format!("open-child-{}", std::process::id());
1245 let watch = crate::porcelain::Watch::start(root.path()).await;
1246 crate::porcelain::install_workspace_child(&open, watch);
1247 let mut ledger = MutationLedger::default();
1248 ledger.observe_tool(
1249 "bash",
1250 0,
1251 Some(&serde_json::json!({ "workspace_child": open })),
1252 );
1253 assert!(ledger.has_open_children());
1254 ledger.observe_tool(
1255 "bash",
1256 0,
1257 Some(&serde_json::json!({ "workspace_child": open })),
1258 );
1259 assert_eq!(ledger.open_children.len(), 1);
1260 crate::porcelain::settle_workspace_child(&open, root.path()).await;
1261 let _ = crate::porcelain::take_settled_workspace_child(&open);
1262 }
1263
1264 #[test]
1265 fn push_ignores_empty_paths() {
1266 let mut ledger = MutationLedger::default();
1267 ledger.observe_tool(
1268 "write",
1269 0,
1270 Some(&serde_json::json!({"file_path": " ", "after": "x"})),
1271 );
1272 assert!(ledger.is_empty());
1273 }
1274
1275 #[test]
1276 fn model_visible_observation_covers_empty_and_already_tagged_output() {
1277 let observation =
1278 MutationObservationV1::diagnostics("src/a.rs", Some(1), vec!["warn".into()]);
1279 let rendered = model_visible_observation("", &observation);
1280 assert!(rendered.contains("[mutation observation]") || !rendered.is_empty());
1281 let tagged = model_visible_observation("[mutation observation]\nprior", &observation);
1282 assert!(tagged.contains("[mutation observation]"));
1283 let combined = model_visible_observation("body", &observation);
1284 assert!(combined.contains("body"));
1285 }
1286
1287 #[test]
1288 fn report_binds_pass_rejects_digest_mismatch_and_empty_required() {
1289 let ledger = ledger_with_write();
1290 let digest = ledger.digest().to_string();
1291 let mismatched = VerificationReport::new(
1292 "edit",
1293 vec![VerificationCheck::required("build", "command", "compiles")
1294 .with_status(VerificationStatus::Passed)],
1295 )
1296 .with_effect_digest("other-digest");
1297 assert!(!report_binds_pass(&mismatched, &digest));
1298 let empty_required = VerificationReport::new(
1299 "edit",
1300 vec![VerificationCheck::optional("note", "info", "n")
1301 .with_status(VerificationStatus::Passed)],
1302 )
1303 .with_effect_digest(&digest);
1304 assert!(!report_binds_pass(&empty_required, &digest));
1305 }
1306
1307 #[test]
1308 fn attach_observation_wraps_non_object_and_none_metadata() {
1309 let observation =
1310 MutationObservationV1::diagnostics("src/a.rs", Some(1), vec!["warn".into()]);
1311 let mut none_meta = None;
1312 attach_observation(&mut none_meta, &observation);
1313 assert!(none_meta
1314 .as_ref()
1315 .unwrap()
1316 .get("mutation_observation")
1317 .is_some());
1318
1319 let mut scalar = Some(serde_json::json!("prior"));
1320 attach_observation(&mut scalar, &observation);
1321 assert_eq!(scalar.as_ref().unwrap()["previous"], "prior");
1322 assert!(scalar
1323 .as_ref()
1324 .unwrap()
1325 .get("mutation_observation")
1326 .is_some());
1327 }
1328
1329 #[tokio::test]
1330 async fn absorb_open_children_skips_nongit_without_snapshots() {
1331 let root = tempfile::tempdir().unwrap();
1332 let mut ledger = MutationLedger::default();
1333 let task_id = format!("nongit-{}", std::process::id());
1334 ledger.open_children.push(OpenWorkspaceChild {
1335 task_id: task_id.clone(),
1336 porcelain: None,
1337 head: None,
1338 nongit: true,
1339 });
1340 let incomplete = absorb_open_workspace_children(
1341 &mut ledger,
1342 root.path(),
1343 &tokio_util::sync::CancellationToken::new(),
1344 )
1345 .await;
1346 assert!(!incomplete);
1347 assert_eq!(ledger.open_children.len(), 1);
1350 assert_eq!(ledger.open_children[0].task_id, task_id);
1351 }
1352
1353 #[tokio::test]
1354 async fn absorb_open_children_deltas_git_backed_markers() {
1355 let root = tempfile::tempdir().unwrap();
1356 let status = std::process::Command::new("git")
1357 .args(["init"])
1358 .current_dir(root.path())
1359 .status()
1360 .unwrap();
1361 assert!(status.success());
1362 std::fs::write(root.path().join("README.md"), "hi\n").unwrap();
1363 let _ = std::process::Command::new("git")
1364 .args(["add", "README.md"])
1365 .current_dir(root.path())
1366 .status();
1367 let _ = std::process::Command::new("git")
1368 .args([
1369 "-c",
1370 "user.email=t@t",
1371 "-c",
1372 "user.name=t",
1373 "commit",
1374 "-m",
1375 "i",
1376 ])
1377 .current_dir(root.path())
1378 .status();
1379
1380 let before_porcelain = crate::porcelain::lines(root.path()).await;
1381 std::fs::write(root.path().join("guest.txt"), "delta\n").unwrap();
1382
1383 let mut ledger = MutationLedger::default();
1384 ledger.open_children.push(OpenWorkspaceChild {
1386 task_id: format!("git-child-{}", std::process::id()),
1387 porcelain: before_porcelain,
1388 head: None,
1389 nongit: false,
1390 });
1391 let incomplete = absorb_open_workspace_children(
1392 &mut ledger,
1393 root.path(),
1394 &tokio_util::sync::CancellationToken::new(),
1395 )
1396 .await;
1397 assert!(!incomplete || ledger.open_children.is_empty());
1398 assert!(ledger.open_children.is_empty());
1399 }
1400}