Skip to main content

a3s_code_core/
harness_loop.rs

1//! Causal completion and mutation observation for the coding loop.
2//!
3//! A mutating run cannot succeed on assistant prose. Verification reports and
4//! host waivers must bind the same effect digest. Diagnostics after a write
5//! are an observation, never a pass.
6
7use 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/// How a successful run closed.
15#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(tag = "kind", rename_all = "snake_case")]
17pub enum CompletionTerminal {
18    /// No workspace mutation. A final answer is enough.
19    #[default]
20    Narrative,
21    /// Required checks passed and were bound to the mutation digest.
22    Verified { effect_digest: String },
23    /// A host-confirmed waiver bound to the mutation digest. Not a verification pass.
24    Waived { effect_digest: String },
25    /// Step closures did not share one effect digest. Not a narrative success
26    /// and not a combined digest. Each digest stays on the step that closed it.
27    Distinct,
28}
29
30impl CompletionTerminal {
31    pub fn is_narrative(&self) -> bool {
32        matches!(self, Self::Narrative)
33    }
34}
35
36/// Fold step closures into the session terminal. A bound closure is not
37/// rewritten as narrative. Different digests are not hashed together.
38pub 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/// Host- or user-confirmed waiver. The model cannot mint this from prose.
80#[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/// Whether this run is an ordinary execution or the admitted exit from plan mode.
101#[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    /// A claim without a non-empty accepted-plan digest is an ordinary run.
121    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/// A background child that shares this workspace and has not been observed yet.
140/// Paths land on this ledger when the child settles. This is not a second digest.
141#[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    /// Background writers still sharing the workspace. Empty means none.
158    #[serde(default, skip_serializing_if = "Vec::is_empty")]
159    open_children: Vec<OpenWorkspaceChild>,
160    /// The latest workspace re-read failed. Not part of the effect digest:
161    /// a partial list is not an identity a waiver can close.
162    #[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    /// Latest content digest recorded for a mutated path (path-boundary match).
180    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    /// True when any record for `path` stored a non-empty content digest.
191    ///
192    /// A later `changed_paths` row hashes tool metadata, not file bytes. Callers
193    /// that need the write's bytes must not treat that later row as the only
194    /// digest.
195    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    /// True when a recorded content digest for `path` is exactly `digest`.
203    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    /// Record a workspace mutation. Ignores reads and tools that did not
215    /// publish a path. `changed_paths` means the workspace already differed,
216    /// including a failed or timed-out wrapper. Command text is not parsed.
217    /// A `file_path` on a failed write is not applied, so it does not count.
218    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    /// The current re-read is authoritative. A later successful read clears
278    /// a previous failure so resume does not stick on a transient git miss.
279    pub fn set_observation_incomplete(&mut self, incomplete: bool) {
280        self.observation_incomplete = incomplete;
281    }
282
283    /// Record a workspace delta the tools did not publish. Paths already on
284    /// the ledger are not added again, so a bound digest stays stable.
285    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
346/// Fold settled background writers into this ledger before the gate runs.
347/// A child that is still running is waited on. Cancellation leaves it open
348/// so narrative success cannot hide the write.
349pub 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
406/// Child tool effects published by a wrapper. Search hits also use `results`,
407/// but they have no `exit_code` and no tool name, so they are not mutations.
408pub(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    /// Ask the model once more, naming the missing evidence. Not a success.
464    Continue {
465        message: String,
466    },
467    /// Do not return `AgentResult` success.
468    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    // A host waiver is not model-grantable, and editor-authored reports are
543    // rejected. Built-in bash may bind a digest only when an existence check
544    // matches a mutated path *and* on-disk content matches the ledger digest.
545    // The optional verifier turn already ran or was skipped before this
546    // decision. Spending the one continuation here cannot close the gate; it
547    // only invites another tool call. An open external observation still
548    // continues, because a workspace write can satisfy that subject.
549    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/// Model-visible observation attached after a mutation. Never a verification pass.
574#[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
633/// Build the observation attached to a mutation. A stale snapshot drops
634/// diagnostic items so a previous revision cannot be presented as current.
635pub 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
647/// Tool-result text the next model call sees. This is the observation
648/// attachment; it is not a `code_diagnostics` tool invocation.
649pub 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        // Nongit markers without snapshots are skipped without settling, so the
1348        // open child remains until a later successful observation clears it.
1349        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        // No pending porcelain slot: absorb must take the delta branch.
1385        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}