Skip to main content

a3s_effect/
coding.rs

1//! Coding harness projected from the log.
2//!
3//! The scheduler does not keep a parked question or a tool confirmation in
4//! process memory. Those waits are phases of the fold. The runtime runs a
5//! transition only when the fold enables one. Answering or confirming appends
6//! a fact, and the next `resume` derives the tool or model call from that fact.
7
8use std::future::Future;
9use std::pin::Pin;
10use std::sync::Arc;
11
12use serde::Deserialize;
13use serde_json::{json, Value};
14use tokio_util::sync::CancellationToken;
15
16use crate::actor::{
17    component, ingest_with_cancel, resume_with_cancel, Actor, ErasedComponent, PreparedTransition,
18    Settlement, Transition, TransitionProgram,
19};
20use crate::effect::{Effect, Schedule};
21use crate::error::ActorError;
22use crate::exit::Exit;
23use crate::fact::{Fact, NewFact};
24
25mod tool_effects;
26pub use tool_effects::{
27    tool_args_digest, ToolAdmissionIdentityV1, ToolEffectObservationV1, ToolExecutionOutcome,
28    ToolExecutionResultV1, ToolFileIdentityV1, ToolFileKind, ToolInvocationIdentityV1,
29    ToolObservationCompleteness, ToolPathChangeV1, ToolPreparedV1, ToolRecoveryPolicy,
30    MAX_TOOL_EFFECT_CANONICAL_BYTES, MAX_TOOL_EFFECT_PATHS, TOOL_PREPARED_SCHEMA,
31    TOOL_RESULT_SCHEMA,
32};
33
34pub type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct ToolSpec {
38    pub name: String,
39    pub description: String,
40}
41
42#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
43pub struct ToolCall {
44    pub id: String,
45    pub name: String,
46    pub args: Value,
47    pub needs_confirmation: bool,
48    /// Prose that accompanied the call. The fold does not branch on it.
49    /// Replaying it is what lets the next model call see its own plan.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub text: Option<String>,
52    /// Provider reasoning that accompanied the call. The fold does not branch
53    /// on it. Absent on logs written before this field existed.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub reasoning: Option<String>,
56}
57
58#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
59#[serde(tag = "kind", rename_all = "snake_case")]
60pub enum ModelDecision {
61    Text {
62        text: String,
63    },
64    Tool {
65        call: ToolCall,
66        /// Further calls from the same model message. Empty on older logs.
67        #[serde(default, skip_serializing_if = "Vec::is_empty")]
68        also: Vec<ToolCall>,
69    },
70    Question {
71        question_id: String,
72        question: String,
73        allow_free_text: bool,
74        /// Options the host renders after the log is reopened. Absent JSON
75        /// fields decode as an empty list.
76        #[serde(default)]
77        options: Vec<String>,
78    },
79}
80
81#[derive(Debug, Clone)]
82pub struct CompletionRequest {
83    pub system: Vec<String>,
84    pub tools: Vec<ToolSpec>,
85    pub summary: String,
86    pub messages: Vec<String>,
87}
88
89/// Model call for the infer transition.
90///
91/// Cancellation ownership: the harness passes the host token into
92/// [`Self::complete`] and never races the call. The implementation owns
93/// mid-flight cancellation — the interactive completion returns a
94/// `ModelDecision::Text` carrying the interrupted-answer sentence so the turn
95/// settles with its transcript — and must return promptly once the token
96/// fires.
97pub trait Completion: Send + Sync {
98    fn complete(
99        &self,
100        request: CompletionRequest,
101        cancel: CancellationToken,
102    ) -> BoxFuture<Result<ModelDecision, ActorError>>;
103
104    /// Provider-reported prompt tokens for the completion that just returned.
105    /// `None` when the provider omitted usage. The infer transition copies this
106    /// onto the `model.turn` fact so a later fold can compact from it.
107    fn reported_prompt_tokens(&self) -> Option<u64> {
108        None
109    }
110}
111
112/// Tool execution for the tool transition.
113///
114/// Cancellation ownership mirrors [`Completion`]: the host token is a
115/// parameter, the runner observes it, and the harness never races the call.
116/// A cancelled call returns a cancelled output value that lands as the
117/// `tool.result` fact.
118#[derive(Debug, Clone)]
119pub struct ToolPreparationRequest {
120    pub identity: ToolInvocationIdentityV1,
121    pub call: ToolCall,
122}
123
124#[derive(Clone)]
125pub struct PreparedToolExecution {
126    pub prepared: ToolPreparedV1,
127    pub execute: Effect<ToolExecutionResultV1, ActorError, CodingServices>,
128}
129
130pub trait ToolRunner: Send + Sync + 'static {
131    fn run(
132        &self,
133        call: ToolCall,
134        cancel: CancellationToken,
135    ) -> BoxFuture<Result<Value, ActorError>>;
136
137    /// Resolve input and authority without executing the tool. The actor persists
138    /// the returned preparation before evaluating `execute`. Legacy adapters are
139    /// supported conservatively: their effect observation is explicitly incomplete.
140    fn prepare(
141        self: Arc<Self>,
142        request: ToolPreparationRequest,
143        _cancel: CancellationToken,
144    ) -> BoxFuture<Result<PreparedToolExecution, ActorError>> {
145        Box::pin(async move {
146            let digest = tool_args_digest(&request.call.args)?;
147            let prepared = ToolPreparedV1 {
148                schema: TOOL_PREPARED_SCHEMA.into(),
149                identity: request.identity,
150                tool_name: request.call.name.clone(),
151                original_args_digest: digest.clone(),
152                effective_args_digest: digest,
153                recovery: ToolRecoveryPolicy::NeverReplay,
154                baseline: None,
155            };
156            let execution_prepared = prepared.clone();
157            let call = request.call;
158            let execute = Effect::from_async_observed(move |_services, cancel| {
159                let runner = Arc::clone(&self);
160                let call = call.clone();
161                let prepared = execution_prepared.clone();
162                async move {
163                    let output = runner.run(call, cancel.clone()).await.map_err(Exit::Fail)?;
164                    Ok(ToolExecutionResultV1 {
165                        schema: TOOL_RESULT_SCHEMA.into(),
166                        identity: prepared.identity.clone(),
167                        prepared_digest: prepared.digest().map_err(Exit::Fail)?,
168                        outcome: if cancel.is_cancelled() {
169                            ToolExecutionOutcome::Cancelled
170                        } else {
171                            ToolExecutionOutcome::Succeeded
172                        },
173                        exit_code: None,
174                        output,
175                        effects: ToolEffectObservationV1::incomplete(
176                            "legacy runner did not observe effects",
177                        ),
178                    })
179                }
180            });
181            Ok(PreparedToolExecution { prepared, execute })
182        })
183    }
184
185    /// True when every call in the batch can run at the same time.
186    /// The default keeps calls in order, one after another.
187    fn can_parallelize(&self, _calls: &[ToolCall]) -> bool {
188        false
189    }
190}
191
192pub trait Compactor: Send + Sync {
193    fn compact(&self, messages: &[String]) -> BoxFuture<Result<String, ActorError>>;
194}
195
196#[derive(Clone)]
197pub struct CodingServices {
198    pub completion: Arc<dyn Completion>,
199    pub tools: Arc<dyn ToolRunner>,
200    pub compactor: Arc<dyn Compactor>,
201}
202
203/// Configuration checked before an actor exists. A zero step limit or a zero
204/// model attempt count cannot construct a harness.
205#[derive(Debug, Clone, PartialEq, Eq)]
206pub struct HarnessConfig {
207    budget: u32,
208    compact_after_chars: usize,
209    step_limit: u32,
210    model_attempts: u32,
211    system: Vec<String>,
212    tools: Vec<ToolSpec>,
213    /// After this many successful tool results in the current turn, the next
214    /// completion request carries an empty tool list. `None` does not cap.
215    tool_round_cap: Option<u32>,
216    /// Compact when the previous model turn reported at least this many prompt
217    /// tokens. `None` keeps the character threshold.
218    compact_after_tokens: Option<u64>,
219    /// Recent transcript to keep verbatim, in estimated tokens. `0` keeps only
220    /// the latest tool call and its result.
221    compact_keep_tokens: usize,
222    /// After this many identical tool calls, the next one is not executed.
223    /// The model receives a tool result that asks it to change approach.
224    /// `None` does not check.
225    duplicate_threshold: Option<u32>,
226    /// Wait this long before replaying a failed model call. `ZERO` retries
227    /// immediately. The host sets a jittered delay for provider failures.
228    model_retry_delay: std::time::Duration,
229}
230
231impl HarnessConfig {
232    pub fn new(
233        budget: u32,
234        compact_after_chars: usize,
235        step_limit: u32,
236        model_attempts: u32,
237        system: Vec<String>,
238        tools: Vec<ToolSpec>,
239    ) -> Result<Self, ActorError> {
240        if step_limit == 0 {
241            return Err(ActorError::Config("step_limit must be at least 1".into()));
242        }
243        if model_attempts == 0 {
244            return Err(ActorError::Config(
245                "model_attempts must be at least 1".into(),
246            ));
247        }
248        Ok(Self {
249            budget,
250            compact_after_chars,
251            step_limit,
252            model_attempts,
253            system,
254            tools,
255            tool_round_cap: None,
256            compact_after_tokens: None,
257            compact_keep_tokens: 0,
258            duplicate_threshold: None,
259            model_retry_delay: std::time::Duration::ZERO,
260        })
261    }
262
263    /// Compact from provider prompt tokens and keep a recent token budget.
264    /// The character threshold no longer fires on this config.
265    pub fn with_token_compact(mut self, after_tokens: u64, keep_tokens: usize) -> Self {
266        self.compact_after_tokens = Some(after_tokens.max(1));
267        self.compact_keep_tokens = keep_tokens.max(1);
268        self.compact_after_chars = usize::MAX;
269        self
270    }
271
272    /// Keep this many estimated tokens of recent transcript when a character
273    /// threshold fires. `0` keeps only the latest tool pair.
274    pub fn with_keep_tokens(mut self, keep_tokens: usize) -> Self {
275        self.compact_keep_tokens = keep_tokens;
276        self
277    }
278
279    /// Take `base`'s token compaction, which a compose recipe does not carry.
280    pub(crate) fn with_token_compact_of(mut self, base: &HarnessConfig) -> Self {
281        if let Some(after_tokens) = base.compact_after_tokens {
282            self = self.with_token_compact(after_tokens, base.compact_keep_tokens);
283        }
284        self
285    }
286
287    /// Soft stop for an identical tool call. The call is not executed.
288    pub fn with_duplicate_threshold(mut self, threshold: u32) -> Self {
289        self.duplicate_threshold = Some(threshold);
290        self
291    }
292
293    /// Delay before a failed model call is tried again.
294    pub fn with_model_retry_delay(mut self, delay: std::time::Duration) -> Self {
295        self.model_retry_delay = delay;
296        self
297    }
298
299    /// One later completion sees no tools once this many tool results exist.
300    /// The caller does not inject a user message to force that completion.
301    pub fn with_tool_round_cap(mut self, cap: u32) -> Self {
302        self.tool_round_cap = Some(cap);
303        self
304    }
305
306    pub fn step_limit(&self) -> u32 {
307        self.step_limit
308    }
309
310    pub fn budget(&self) -> u32 {
311        self.budget
312    }
313
314    pub fn compact_after_chars(&self) -> usize {
315        self.compact_after_chars
316    }
317
318    pub fn model_attempts(&self) -> u32 {
319        self.model_attempts
320    }
321
322    pub fn system(&self) -> &[String] {
323        &self.system
324    }
325
326    pub fn tools(&self) -> &[ToolSpec] {
327        &self.tools
328    }
329
330    pub fn tool_round_cap(&self) -> Option<u32> {
331        self.tool_round_cap
332    }
333
334    pub fn model_retry_delay(&self) -> std::time::Duration {
335        self.model_retry_delay
336    }
337}
338
339#[derive(Debug, Clone, PartialEq)]
340pub struct PendingConfirmation {
341    pub tool_call_id: String,
342    pub name: String,
343    pub args: Value,
344}
345
346#[derive(Debug, Clone, PartialEq, Eq)]
347pub struct PendingQuestion {
348    pub question_id: String,
349    pub question: String,
350    pub allow_free_text: bool,
351    pub options: Vec<String>,
352}
353
354#[derive(Debug, Clone, Copy, PartialEq, Eq)]
355pub enum CodingPhase {
356    Idle,
357    Infer,
358    Compact,
359    Confirm,
360    Question,
361    Tool,
362    Deny,
363    Done,
364}
365
366#[derive(Debug, Clone, PartialEq)]
367pub struct CodingView {
368    pub system: Vec<String>,
369    pub tools: Vec<ToolSpec>,
370    pub pending_confirmation: Option<PendingConfirmation>,
371    pub pending_question: Option<PendingQuestion>,
372    pub assistant: Option<String>,
373    pub phase: CodingPhase,
374    pub schema_error: Option<String>,
375    /// Composed budget policy slot (`budget(...)` part).
376    pub tool_budget: Option<u32>,
377    /// Composed compaction threshold slot (`compact(...)` part).
378    pub compact_after_chars: Option<usize>,
379}
380
381impl CodingView {
382    pub fn empty() -> Self {
383        Self {
384            system: Vec::new(),
385            tools: Vec::new(),
386            pending_confirmation: None,
387            pending_question: None,
388            assistant: None,
389            phase: CodingPhase::Idle,
390            schema_error: None,
391            tool_budget: None,
392            compact_after_chars: None,
393        }
394    }
395}
396
397pub fn merge_coding_view(views: Vec<CodingView>) -> CodingView {
398    CodingView {
399        system: views.iter().flat_map(|view| view.system.clone()).collect(),
400        tools: views.iter().flat_map(|view| view.tools.clone()).collect(),
401        pending_confirmation: views
402            .iter()
403            .find_map(|view| view.pending_confirmation.clone()),
404        pending_question: views.iter().find_map(|view| view.pending_question.clone()),
405        assistant: views.iter().rev().find_map(|view| view.assistant.clone()),
406        phase: views
407            .iter()
408            .rev()
409            .find(|view| view.phase != CodingPhase::Idle)
410            .map(|view| view.phase)
411            .unwrap_or(CodingPhase::Idle),
412        schema_error: views.iter().find_map(|view| view.schema_error.clone()),
413        tool_budget: views.iter().find_map(|view| view.tool_budget),
414        compact_after_chars: views.iter().find_map(|view| view.compact_after_chars),
415    }
416}
417
418#[derive(Debug, Clone)]
419struct SchedulerState {
420    turn: u64,
421    cycle: u64,
422    phase: CodingPhase,
423    messages: Vec<String>,
424    summary: String,
425    tool_runs: u32,
426    /// A compaction landed and no model call has reported since. Pressure is
427    /// measured again only after the model has seen the compacted prompt.
428    compacted_awaiting_model: bool,
429    assistant: Option<String>,
430    pending_call: Option<ToolCall>,
431    /// Calls from the same model message that have not run yet.
432    queued: Vec<ToolCall>,
433    pending_question: Option<PendingQuestion>,
434    schema_error: Option<String>,
435    prompt_tokens: Option<u64>,
436    /// Background-arrival lines folded while the protocol was mid-step.
437    /// Flush points move them into `messages` (see [`flush_background`]).
438    background_pending: Vec<String>,
439    /// Set once [`BACKGROUND_PENDING_CAP`] overflowed; the flush renders one
440    /// truncation marker instead of the dropped lines. Replay-deterministic.
441    background_truncated: bool,
442    preparations: Vec<ToolPreparedV1>,
443    completed_invocations: Vec<ToolInvocationIdentityV1>,
444    legacy_pending: bool,
445}
446
447/// Rendered when background arrivals exceeded [`BACKGROUND_PENDING_CAP`]
448/// while the protocol was mid-step. The producer-side stream is separately
449/// capped per task; this marker bounds the fold-side buffer.
450const BACKGROUND_TRUNCATION_LINE: &str =
451    "[background] …pending arrivals truncated (background pending cap reached)";
452
453/// Buffered background arrivals are bounded: at most this many lines wait for
454/// the next flush point.
455const BACKGROUND_PENDING_CAP: usize = 64;
456
457impl SchedulerState {
458    fn new() -> Self {
459        Self {
460            turn: 0,
461            cycle: 0,
462            phase: CodingPhase::Idle,
463            messages: Vec::new(),
464            summary: String::new(),
465            tool_runs: 0,
466            compacted_awaiting_model: false,
467            assistant: None,
468            pending_call: None,
469            queued: Vec::new(),
470            pending_question: None,
471            schema_error: None,
472            prompt_tokens: None,
473            background_pending: Vec::new(),
474            background_truncated: false,
475            preparations: Vec::new(),
476            completed_invocations: Vec::new(),
477            legacy_pending: false,
478        }
479    }
480
481    fn size(&self) -> usize {
482        self.summary.len()
483            + self.messages.iter().map(String::len).sum::<usize>()
484            + self
485                .background_pending
486                .iter()
487                .map(String::len)
488                .sum::<usize>()
489    }
490}
491
492/// Move buffered background arrivals into `messages`.
493///
494/// Call only at protocol-safe points: when `messages` ends at a completed
495/// step (a finished tool batch, an answered question, right before a new
496/// user line) — never between an `assistant\ntool` line and its results,
497/// and never before the `model.turn` fact of a model call that already ran
498/// without them. Every call site satisfies both by construction; the fold
499/// order of the log keeps replay deterministic.
500fn flush_background(state: SchedulerState) -> SchedulerState {
501    if state.background_pending.is_empty() && !state.background_truncated {
502        return state;
503    }
504    let mut messages = state.messages.clone();
505    if state.background_truncated {
506        messages.push(BACKGROUND_TRUNCATION_LINE.to_string());
507    }
508    messages.extend(state.background_pending.iter().cloned());
509    SchedulerState {
510        messages,
511        background_pending: Vec::new(),
512        background_truncated: false,
513        ..state
514    }
515}
516
517/// Fold one `background.output` fact. While the protocol is mid-step — a
518/// model call in flight, a tool batch or confirmation pending, a compaction
519/// rewriting the transcript — the rendered line buffers; otherwise it lands
520/// directly after the last completed step.
521fn on_background_output(state: SchedulerState, fact: &Fact) -> SchedulerState {
522    let payload = match serde_json::from_value::<BackgroundOutputPayload>(fact.payload.clone()) {
523        Ok(payload) => payload,
524        Err(error) => {
525            return SchedulerState {
526                phase: CodingPhase::Done,
527                schema_error: Some(error.to_string()),
528                ..state
529            }
530        }
531    };
532    let line = payload.render();
533    let mid_step = state.pending_call.is_some()
534        || !state.queued.is_empty()
535        || matches!(
536            state.phase,
537            CodingPhase::Infer
538                | CodingPhase::Tool
539                | CodingPhase::Confirm
540                | CodingPhase::Deny
541                | CodingPhase::Compact
542        );
543    if !mid_step {
544        let mut messages = state.messages.clone();
545        messages.push(line);
546        return SchedulerState { messages, ..state };
547    }
548    if state.background_pending.len() >= BACKGROUND_PENDING_CAP {
549        return SchedulerState {
550            background_truncated: true,
551            ..state
552        };
553    }
554    let mut background_pending = state.background_pending.clone();
555    background_pending.push(line);
556    SchedulerState {
557        background_pending,
558        ..state
559    }
560}
561
562/// One emitted line of a Run-owned background task, appended by the task's
563/// reader through the normal ingest path (`background.output` facts on the
564/// session's one fact log).
565#[derive(Deserialize)]
566struct BackgroundOutputPayload {
567    name: String,
568    line: String,
569    /// `truncated` marks the producer-side stream cap; `exited` carries the
570    /// final disposition line. Absent for a plain output line.
571    #[serde(default)]
572    event: Option<String>,
573    /// `stderr` when the line came from the task's standard error.
574    #[serde(default)]
575    stream: Option<String>,
576}
577
578impl BackgroundOutputPayload {
579    fn render(&self) -> String {
580        match self.event.as_deref() {
581            Some("truncated") => format!(
582                "[background:{}] …output truncated (recorded-line cap reached; the task keeps running but its output is no longer recorded)",
583                self.name
584            ),
585            Some("exited") => format!("[background:{}] exited: {}", self.name, self.line),
586            _ => {
587                if self.stream.as_deref() == Some("stderr") {
588                    format!("[background:{}] (stderr) {}", self.name, self.line)
589                } else {
590                    format!("[background:{}] {}", self.name, self.line)
591                }
592            }
593        }
594    }
595}
596
597fn after_input(state: SchedulerState, config: &HarnessConfig) -> SchedulerState {
598    let (prefix, _) = split_compact_tail(&state.messages, config.compact_keep_tokens);
599    let token_pressure = config
600        .compact_after_tokens
601        .is_some_and(|limit| state.prompt_tokens.is_some_and(|used| used >= limit));
602    let char_pressure =
603        config.compact_after_tokens.is_none() && state.size() >= config.compact_after_chars;
604    let needs_compact =
605        !prefix.is_empty() && (token_pressure || char_pressure) && !state.compacted_awaiting_model;
606    SchedulerState {
607        phase: if needs_compact {
608            CodingPhase::Compact
609        } else {
610            CodingPhase::Infer
611        },
612        assistant: None,
613        pending_call: None,
614        pending_question: None,
615        ..state
616    }
617}
618
619fn prompt_tokens_of(payload: &Value) -> Option<u64> {
620    payload
621        .get("prompt_tokens")
622        .and_then(|value| value.as_u64())
623        .filter(|tokens| *tokens > 0)
624}
625
626fn on_model(mut state: SchedulerState, config: &HarnessConfig, payload: &Value) -> SchedulerState {
627    let prompt_tokens = prompt_tokens_of(payload).or(state.prompt_tokens);
628    state.prompt_tokens = prompt_tokens;
629    state.compacted_awaiting_model = false;
630    let decided = match serde_json::from_value::<ModelDecision>(payload.clone()) {
631        Ok(decided) => decided,
632        Err(error) => {
633            state.phase = CodingPhase::Done;
634            state.schema_error = Some(error.to_string());
635            state.pending_call = None;
636            return state;
637        }
638    };
639    match decided {
640        ModelDecision::Text { text } => {
641            // The immutable model fact is also conversation history. Keep it
642            // in the same fold so later inference and compaction see the reply.
643            state.messages.push(format!("assistant\n{text}"));
644            SchedulerState {
645                phase: CodingPhase::Done,
646                assistant: Some(text),
647                pending_call: None,
648                ..state
649            }
650        }
651        ModelDecision::Question {
652            question_id,
653            question,
654            allow_free_text,
655            options,
656        } => {
657            state.messages.push(format!("assistant\n{question}"));
658            SchedulerState {
659                phase: CodingPhase::Question,
660                pending_question: Some(PendingQuestion {
661                    question_id,
662                    question,
663                    allow_free_text,
664                    options,
665                }),
666                pending_call: None,
667                ..state
668            }
669        }
670        ModelDecision::Tool { call, also } => {
671            let legacy_pending = match payload.get("tool_effect_protocol") {
672                None => true,
673                Some(value) if value.as_u64() == Some(1) => false,
674                Some(_) => {
675                    state.schema_error =
676                        Some("tool_effect_schema_unsupported: model tool protocol".into());
677                    state.phase = CodingPhase::Done;
678                    return state;
679                }
680            };
681            let mut calls = Vec::with_capacity(1 + also.len());
682            calls.push(call);
683            calls.extend(also);
684            let mut ids = std::collections::BTreeSet::new();
685            if calls.iter().any(|call| !ids.insert(call.id.as_str())) {
686                state.schema_error = Some(
687                    "tool_effect_identity_mismatch: duplicate tool call id in model batch".into(),
688                );
689                state.phase = CodingPhase::Done;
690                return state;
691            }
692            let mut messages = state.messages.clone();
693            for call in &calls {
694                messages.push(format!("assistant\ntool {}\n{}", call.name, call.args));
695            }
696            let Some(first) = calls.first().cloned() else {
697                return SchedulerState {
698                    phase: CodingPhase::Done,
699                    schema_error: Some("model tool decision had no call".into()),
700                    ..state
701                };
702            };
703            let queued = calls.into_iter().skip(1).collect();
704            let recorded = SchedulerState {
705                messages,
706                queued,
707                legacy_pending,
708                ..state
709            };
710            if recorded.tool_runs >= config.budget {
711                SchedulerState {
712                    phase: CodingPhase::Deny,
713                    pending_call: Some(first),
714                    ..recorded
715                }
716            } else if first.needs_confirmation {
717                SchedulerState {
718                    phase: CodingPhase::Confirm,
719                    pending_call: Some(first),
720                    ..recorded
721                }
722            } else {
723                SchedulerState {
724                    phase: CodingPhase::Tool,
725                    pending_call: Some(first),
726                    ..recorded
727                }
728            }
729        }
730    }
731}
732
733#[derive(Deserialize)]
734struct TextPayload {
735    text: String,
736}
737
738#[derive(Deserialize)]
739struct SummaryPayload {
740    summary: String,
741    #[serde(default)]
742    kept: Vec<String>,
743}
744
745fn estimate_tokens(text: &str) -> usize {
746    text.len().div_ceil(4).max(1)
747}
748
749fn is_tool_result_line(message: &str) -> bool {
750    message.starts_with("tool\n") || message.starts_with("tool-error\n")
751}
752
753fn tool_result_line(ok: bool, output: &str) -> String {
754    if ok {
755        format!("tool\n{output}")
756    } else {
757        format!("tool-error\n{output}")
758    }
759}
760
761fn promote_queued(mut state: SchedulerState) -> SchedulerState {
762    if state.queued.is_empty() {
763        state.pending_call = None;
764        return state;
765    }
766    let next = state.queued.remove(0);
767    state.phase = if next.needs_confirmation {
768        CodingPhase::Confirm
769    } else {
770        CodingPhase::Tool
771    };
772    state.pending_call = Some(next);
773    state.pending_question = None;
774    state
775}
776
777/// Older lines are summarized. `keep_tokens == 0` retains only the newest tool
778/// result, plus the preceding line when that line is its own record.
779/// A positive budget walks backward by scheduler regions. A run of
780/// `assistant\ntool ` records followed by one or more results is one region,
781/// so the tail cannot keep the last record glued to another call's result.
782/// Records with no following result stay single lines.
783fn split_compact_tail(messages: &[String], keep_tokens: usize) -> (Vec<String>, Vec<String>) {
784    if messages.is_empty() {
785        return (Vec::new(), Vec::new());
786    }
787    if keep_tokens == 0 {
788        let Some(tool_at) = messages
789            .iter()
790            .rposition(|message| is_tool_result_line(message))
791        else {
792            return (messages.to_vec(), Vec::new());
793        };
794        let start = if tool_at > 0 && messages[tool_at - 1].starts_with("assistant\ntool ") {
795            tool_at - 1
796        } else {
797            tool_at
798        };
799        return (messages[..start].to_vec(), messages[start..].to_vec());
800    }
801    const RECORD_PREFIX: &str = "assistant\ntool ";
802    let mut units = Vec::new();
803    let mut index = 0;
804    while index < messages.len() {
805        if !messages[index].starts_with(RECORD_PREFIX) {
806            units.push((index, index + 1));
807            index += 1;
808            continue;
809        }
810        let start = index;
811        while index < messages.len() && messages[index].starts_with(RECORD_PREFIX) {
812            index += 1;
813        }
814        let records = index - start;
815        let mut results = 0;
816        while index + results < messages.len() && is_tool_result_line(&messages[index + results]) {
817            results += 1;
818        }
819        if results > 0 {
820            units.push((start, index + results));
821            index += results;
822        } else {
823            index = start;
824            while index < start + records {
825                units.push((index, index + 1));
826                index += 1;
827            }
828        }
829    }
830    let mut start_unit = units.len() - 1;
831    let mut used = messages[units[start_unit].0..units[start_unit].1]
832        .iter()
833        .map(|line| estimate_tokens(line))
834        .sum::<usize>();
835    while start_unit > 0 {
836        let previous = start_unit - 1;
837        let extra = messages[units[previous].0..units[previous].1]
838            .iter()
839            .map(|line| estimate_tokens(line))
840            .sum::<usize>();
841        if used.saturating_add(extra) > keep_tokens {
842            break;
843        }
844        used = used.saturating_add(extra);
845        start_unit = previous;
846    }
847    let start = units[start_unit].0;
848    (messages[..start].to_vec(), messages[start..].to_vec())
849}
850
851fn duplicate_nudge(messages: &[String], call: &ToolCall, threshold: Option<u32>) -> Option<String> {
852    let threshold = threshold.filter(|value| *value > 0)?;
853    let line = format!("assistant\ntool {}\n{}", call.name, call.args);
854    let count = messages
855        .iter()
856        .filter(|message| message.as_str() == line)
857        .count();
858    if count <= threshold as usize {
859        return None;
860    }
861    Some(format!(
862        "Tool '{}' has been called {} times with identical arguments. Change your approach instead of repeating this call.",
863        call.name, count
864    ))
865}
866
867#[derive(Deserialize)]
868struct ConfirmationPayload {
869    tool_call_id: String,
870    approved: bool,
871}
872
873#[derive(Deserialize)]
874struct ToolResultPayload {
875    #[serde(default, rename = "toolCallId")]
876    tool_call_id: Option<String>,
877    #[serde(default)]
878    receipt: Option<ToolExecutionResultV1>,
879    ok: bool,
880    output: String,
881    /// When set, this result counts toward the tool ceiling even if `ok` is false.
882    /// Absent logs count only successful results, which is the older behavior.
883    #[serde(default, rename = "countRun")]
884    count_run: Option<bool>,
885}
886
887fn step(mut state: SchedulerState, fact: &Fact, config: &HarnessConfig) -> SchedulerState {
888    match fact.kind.as_str() {
889        "user.message" => match serde_json::from_value::<TextPayload>(fact.payload.clone()) {
890            Ok(payload) => {
891                // Arrivals that belonged to the previous turn land before the
892                // new human line, which is where the next model call reads them.
893                let state = flush_background(state);
894                after_input(
895                    SchedulerState {
896                        turn: state.turn + 1,
897                        cycle: 0,
898                        messages: {
899                            let mut messages = state.messages.clone();
900                            messages.push(format!("user\n{}", payload.text));
901                            messages
902                        },
903                        tool_runs: 0,
904                        ..state
905                    },
906                    config,
907                )
908            }
909            Err(error) => {
910                state.phase = CodingPhase::Done;
911                state.schema_error = Some(error.to_string());
912                state
913            }
914        },
915        "compaction.done" => match serde_json::from_value::<SummaryPayload>(fact.payload.clone()) {
916            Ok(payload) => SchedulerState {
917                summary: payload.summary,
918                compacted_awaiting_model: true,
919                // The count before compaction no longer describes the prompt.
920                prompt_tokens: None,
921                phase: CodingPhase::Infer,
922                messages: payload.kept,
923                ..state
924            },
925            Err(error) => {
926                state.phase = CodingPhase::Done;
927                state.schema_error = Some(error.to_string());
928                state
929            }
930        },
931        "model.turn" => on_model(state, config, &fact.payload),
932        "confirmation.answered" => {
933            let Some(call) = state.pending_call.clone() else {
934                return state;
935            };
936            match serde_json::from_value::<ConfirmationPayload>(fact.payload.clone()) {
937                Ok(payload) if payload.tool_call_id == call.id && payload.approved => {
938                    SchedulerState {
939                        phase: CodingPhase::Tool,
940                        pending_question: None,
941                        ..state
942                    }
943                }
944                Ok(payload) if payload.tool_call_id == call.id => {
945                    if state.queued.is_empty() {
946                        SchedulerState {
947                            phase: CodingPhase::Done,
948                            assistant: Some("denied".into()),
949                            pending_call: None,
950                            ..state
951                        }
952                    } else {
953                        let mut continued = SchedulerState {
954                            cycle: state.cycle + 1,
955                            messages: {
956                                let mut messages = state.messages.clone();
957                                messages.push(tool_result_line(false, "denied"));
958                                messages
959                            },
960                            tool_runs: state.tool_runs.saturating_add(1),
961                            ..state
962                        };
963                        continued = promote_queued(continued);
964                        continued
965                    }
966                }
967                Ok(_) => state,
968                Err(error) => {
969                    state.schema_error = Some(error.to_string());
970                    state.phase = CodingPhase::Done;
971                    state
972                }
973            }
974        }
975        "tool.prepared" => {
976            let parsed = serde_json::from_value::<ToolPreparedV1>(
977                fact.payload.get("prepared").cloned().unwrap_or(Value::Null),
978            )
979            .map_err(|error| ActorError::Schema(error.to_string()))
980            .and_then(|prepared| {
981                prepared.validate()?;
982                let Some(call) = state
983                    .pending_call
984                    .as_ref()
985                    .filter(|call| call.id == prepared.identity.tool_call_id)
986                    .or_else(|| {
987                        state
988                            .queued
989                            .iter()
990                            .find(|call| call.id == prepared.identity.tool_call_id)
991                    })
992                else {
993                    return Err(ActorError::Schema(
994                        "tool_effect_identity_mismatch: preparation has no pending call".into(),
995                    ));
996                };
997                let batch = ready_tool_batch(
998                    state.pending_call.as_ref().ok_or_else(|| {
999                        ActorError::Schema("tool_effect_identity_mismatch: no selected call".into())
1000                    })?,
1001                    &state.queued,
1002                );
1003                let index = usize::try_from(prepared.identity.batch_index).map_err(|_| {
1004                    ActorError::Schema("tool_effect_identity_mismatch: batch index overflow".into())
1005                })?;
1006                if batch
1007                    .get(index)
1008                    .is_none_or(|expected| expected.id != call.id)
1009                {
1010                    return Err(ActorError::Schema(
1011                        "tool_effect_identity_mismatch: wrong batch position".into(),
1012                    ));
1013                }
1014                // Every preparation in a batch binds its original batch cycle.
1015                if prepared.identity.turn != state.turn
1016                    || prepared.identity.cycle != state.cycle
1017                    || call.name != prepared.tool_name
1018                    || tool_args_digest(&call.args)? != prepared.original_args_digest
1019                {
1020                    return Err(ActorError::Schema(
1021                        "tool_effect_identity_mismatch: preparation differs from selected call"
1022                            .into(),
1023                    ));
1024                }
1025                if state
1026                    .preparations
1027                    .iter()
1028                    .any(|earlier| earlier.identity.matches_scheduler(&prepared.identity))
1029                    || state
1030                        .completed_invocations
1031                        .iter()
1032                        .any(|earlier| earlier.matches_scheduler(&prepared.identity))
1033                {
1034                    return Err(ActorError::Schema(
1035                        "tool_effect_identity_mismatch: duplicate preparation".into(),
1036                    ));
1037                }
1038                Ok(prepared)
1039            });
1040            match parsed {
1041                Ok(prepared) => state.preparations.push(prepared),
1042                Err(error) => {
1043                    state.schema_error = Some(error.to_string());
1044                    state.phase = CodingPhase::Done;
1045                }
1046            }
1047            state
1048        }
1049        "tool.result" if fact.payload.get("receipt") == Some(&Value::Null) => {
1050            state.schema_error = Some("tool_effect_schema_unsupported: null receipt".into());
1051            state.phase = CodingPhase::Done;
1052            state
1053        }
1054        "tool.result" => match serde_json::from_value::<ToolResultPayload>(fact.payload.clone()) {
1055            Ok(payload) => {
1056                if let Err(error) = validate_folded_result(&state, &payload) {
1057                    state.schema_error = Some(error.to_string());
1058                    state.phase = CodingPhase::Done;
1059                    return state;
1060                }
1061                if let Some(receipt) = &payload.receipt {
1062                    state
1063                        .preparations
1064                        .retain(|prepared| prepared.identity != receipt.identity);
1065                    state.completed_invocations.push(receipt.identity.clone());
1066                }
1067                let counts = payload.count_run.unwrap_or(payload.ok);
1068                let folded = SchedulerState {
1069                    tool_runs: state.tool_runs + u32::from(counts),
1070                    cycle: state.cycle + 1,
1071                    messages: {
1072                        let mut messages = state.messages.clone();
1073                        messages.push(tool_result_line(payload.ok, &payload.output));
1074                        messages
1075                    },
1076                    ..state
1077                };
1078                let folded = promote_queued(folded);
1079                if folded.pending_call.is_none() {
1080                    // The batch is complete: arrivals buffered while the tools
1081                    // ran are the next thing the model should read.
1082                    after_input(flush_background(folded), config)
1083                } else {
1084                    folded
1085                }
1086            }
1087            Err(error) => {
1088                state.schema_error = Some(error.to_string());
1089                state.phase = CodingPhase::Done;
1090                state
1091            }
1092        },
1093        "question.answered" => match serde_json::from_value::<TextPayload>(fact.payload.clone()) {
1094            Ok(payload) => {
1095                // Same placement rule as `user.message`: buffered arrivals
1096                // precede the answer text the fold is about to append.
1097                let state = flush_background(state);
1098                after_input(
1099                    SchedulerState {
1100                        cycle: state.cycle + 1,
1101                        messages: {
1102                            let mut messages = state.messages.clone();
1103                            messages.push(format!("user\n{}", payload.text));
1104                            messages
1105                        },
1106                        pending_question: None,
1107                        ..state
1108                    },
1109                    config,
1110                )
1111            }
1112            Err(error) => {
1113                state.schema_error = Some(error.to_string());
1114                state.phase = CodingPhase::Done;
1115                state
1116            }
1117        },
1118        "budget.denied" => SchedulerState {
1119            phase: CodingPhase::Done,
1120            assistant: Some("budget".into()),
1121            pending_call: None,
1122            queued: Vec::new(),
1123            ..state
1124        },
1125        "background.output" => on_background_output(state, fact),
1126        _ => state,
1127    }
1128}
1129
1130fn view_of(state: &SchedulerState) -> CodingView {
1131    CodingView {
1132        pending_confirmation: match (&state.phase, &state.pending_call) {
1133            (CodingPhase::Confirm, Some(call)) => Some(PendingConfirmation {
1134                tool_call_id: call.id.clone(),
1135                name: call.name.clone(),
1136                args: call.args.clone(),
1137            }),
1138            _ => None,
1139        },
1140        pending_question: match state.phase {
1141            CodingPhase::Question => state.pending_question.clone(),
1142            _ => None,
1143        },
1144        assistant: state.assistant.clone(),
1145        phase: state.phase,
1146        schema_error: state.schema_error.clone(),
1147        ..CodingView::empty()
1148    }
1149}
1150
1151fn transitions_of(
1152    state: &SchedulerState,
1153    config: &HarnessConfig,
1154) -> Vec<Transition<CodingServices>> {
1155    if state.schema_error.is_some() {
1156        return Vec::new();
1157    }
1158    match state.phase {
1159        CodingPhase::Compact => {
1160            let (prefix, kept) = split_compact_tail(&state.messages, config.compact_keep_tokens);
1161            let mut to_summarize = prefix;
1162            if !state.summary.is_empty() {
1163                to_summarize.insert(0, state.summary.clone());
1164            }
1165            let (turn, cycle) = (state.turn, state.cycle);
1166            vec![Transition {
1167                key: format!("compact:{turn}:{cycle}"),
1168                run: Effect::from_async_observed(move |services: Arc<CodingServices>, cancel| {
1169                    let to_summarize = to_summarize.clone();
1170                    let kept = kept.clone();
1171                    async move {
1172                        tokio::select! {
1173                            biased;
1174                            _ = cancel.cancelled() => Err(Exit::Interrupt),
1175                            summary = services.compactor.compact(&to_summarize) => {
1176                                let summary = summary?;
1177                                Ok(vec![NewFact {
1178                                    kind: "compaction.done".into(),
1179                                    key: format!("compaction:{turn}:{cycle}"),
1180                                    payload: json!({ "summary": summary, "kept": kept }),
1181                                }])
1182                            }
1183                        }
1184                    }
1185                })
1186                .into(),
1187            }]
1188        }
1189        CodingPhase::Infer => {
1190            let tools = match config.tool_round_cap {
1191                Some(cap) if state.tool_runs >= cap => Vec::new(),
1192                _ => config.tools.clone(),
1193            };
1194            let request = CompletionRequest {
1195                system: config.system.clone(),
1196                tools,
1197                summary: state.summary.clone(),
1198                messages: state.messages.clone(),
1199            };
1200            let turn = state.turn;
1201            let cycle = state.cycle;
1202            let attempts = config.model_attempts;
1203            let retry_delay = config.model_retry_delay;
1204            vec![Transition {
1205                key: format!("infer:{turn}:{cycle}"),
1206                // Mid-flight cancellation belongs to the completion: an
1207                // observing completion lands its own interrupted decision and
1208                // session transcript, and racing it here with a select would
1209                // discard that work and leave the turn without its interrupt
1210                // answer. Only the not-yet-started turn is refused here.
1211                run: Effect::from_async_observed(move |services: Arc<CodingServices>, cancel| {
1212                    let request = request.clone();
1213                    async move {
1214                        if cancel.is_cancelled() {
1215                            return interrupted_model_turn(turn, cycle);
1216                        }
1217                        let decided = services.completion.complete(request, cancel.clone()).await;
1218                        let decided = match decided {
1219                            Ok(decided) => decided,
1220                            Err(ActorError::Defect(message)) => {
1221                                return Err(Exit::Die(message));
1222                            }
1223                            Err(error) => return Err(Exit::Fail(error)),
1224                        };
1225                        let mut payload = serde_json::to_value(&decided)
1226                            .map_err(|error| Exit::Die(error.to_string()))?;
1227                        // A protocol-one tool decision guarantees that no tool
1228                        // can start without the durable preparation boundary.
1229                        payload["tool_effect_protocol"] = json!(1);
1230                        if let Some(tokens) = services.completion.reported_prompt_tokens() {
1231                            payload["prompt_tokens"] = json!(tokens);
1232                        }
1233                        Ok(vec![NewFact {
1234                            kind: "model.turn".into(),
1235                            key: format!("model:{turn}:{cycle}"),
1236                            payload,
1237                        }])
1238                    }
1239                })
1240                .retry(Schedule {
1241                    remaining: attempts.saturating_sub(1),
1242                    delay: retry_delay,
1243                })
1244                .into(),
1245            }]
1246        }
1247        CodingPhase::Tool => {
1248            let Some(call) = state.pending_call.clone() else {
1249                return Vec::new();
1250            };
1251            let batch = ready_tool_batch(&call, &state.queued);
1252            let turn = state.turn;
1253            let cycle = state.cycle;
1254            // The same durable prepared state is ambiguous after process death.
1255            // Only the original selected continuation executes; replay records
1256            // Unknown, preserving accountability without repeating an operation.
1257            let unresolved: Vec<_> = state
1258                .preparations
1259                .iter()
1260                .filter(|prepared| {
1261                    prepared.identity.turn == turn
1262                        && batch
1263                            .iter()
1264                            .any(|call| call.id == prepared.identity.tool_call_id)
1265                })
1266                .cloned()
1267                .collect();
1268            if !unresolved.is_empty() {
1269                let key = format!("tool-reconcile:{turn}:{cycle}:{}", call.id);
1270                return vec![Transition {
1271                    key,
1272                    run: Effect::from_async_observed(move |_services, _cancel| {
1273                        let unresolved = unresolved.clone();
1274                        async move {
1275                            unresolved
1276                                .iter()
1277                                .map(|prepared| {
1278                                    let result = ToolExecutionResultV1::unknown(
1279                                        prepared,
1280                                        "prepared invocation has no committed terminal result",
1281                                    )
1282                                    .map_err(Exit::Fail)?;
1283                                    receipt_fact(prepared, result).map_err(Exit::Fail)
1284                                })
1285                                .collect()
1286                        }
1287                    })
1288                    .into(),
1289                }];
1290            }
1291            if state.legacy_pending {
1292                return vec![Transition {
1293                    key: format!("tool-legacy-unresolved:{turn}:{cycle}:{}", call.id),
1294                    run: Effect::succeed(batch.iter().map(|call| {
1295                        let mut fact = tool_result_fact(turn, cycle, &call.id, false,
1296                            "tool_effect_unresolved: legacy invocation has no authoritative execution evidence".into(), true);
1297                        fact.payload["unresolvedLegacy"] = json!(true);
1298                        fact
1299                    }).collect()).into(),
1300                }];
1301            }
1302            let messages = state.messages.clone();
1303            let threshold = config.duplicate_threshold;
1304            let key = batch
1305                .iter()
1306                .map(|call| call.id.as_str())
1307                .collect::<Vec<_>>()
1308                .join("+");
1309            vec![Transition {
1310                key: format!("tool:{turn}:{cycle}:{key}"),
1311                run: TransitionProgram::Prepared(Effect::from_async_observed(
1312                    move |services: Arc<CodingServices>, cancel| {
1313                        let batch = batch.clone();
1314                        let messages = messages.clone();
1315                        async move {
1316                            prepare_tool_batch(
1317                                services, batch, &messages, threshold, turn, cycle, cancel,
1318                            )
1319                            .await
1320                        }
1321                    },
1322                )),
1323            }]
1324        }
1325        CodingPhase::Deny => {
1326            let Some(call) = state.pending_call.clone() else {
1327                return Vec::new();
1328            };
1329            vec![Transition {
1330                key: format!("budget:{}:{}:{}", state.turn, state.cycle, call.id),
1331                run: Effect::succeed(vec![NewFact {
1332                    kind: "budget.denied".into(),
1333                    key: format!("budget-denied:{}:{}:{}", state.turn, state.cycle, call.id),
1334                    payload: json!({ "toolCallId": call.id }),
1335                }])
1336                .into(),
1337            }]
1338        }
1339        CodingPhase::Idle | CodingPhase::Confirm | CodingPhase::Question | CodingPhase::Done => {
1340            Vec::new()
1341        }
1342    }
1343}
1344
1345fn ready_tool_batch(pending: &ToolCall, queued: &[ToolCall]) -> Vec<ToolCall> {
1346    let mut batch = vec![pending.clone()];
1347    for call in queued {
1348        if call.needs_confirmation {
1349            break;
1350        }
1351        batch.push(call.clone());
1352    }
1353    batch
1354}
1355
1356fn tool_output_text(value: &Value) -> String {
1357    match value {
1358        Value::String(text) => text.clone(),
1359        other => other.to_string(),
1360    }
1361}
1362
1363fn interrupted_model_turn(turn: u64, cycle: u64) -> Result<Vec<NewFact>, Exit<ActorError>> {
1364    let decided = ModelDecision::Text {
1365        text: "(Response interrupted by the user.)".into(),
1366    };
1367    let payload = serde_json::to_value(&decided).map_err(|error| Exit::Die(error.to_string()))?;
1368    Ok(vec![NewFact {
1369        kind: "model.turn".into(),
1370        key: format!("model:{turn}:{cycle}"),
1371        payload,
1372    }])
1373}
1374
1375fn tool_result_fact(
1376    turn: u64,
1377    cycle: u64,
1378    id: &str,
1379    ok: bool,
1380    output: String,
1381    count_run: bool,
1382) -> NewFact {
1383    let mut payload = json!({ "toolCallId": id, "ok": ok, "output": output });
1384    if count_run {
1385        payload["countRun"] = json!(true);
1386    }
1387    NewFact {
1388        kind: "tool.result".into(),
1389        key: format!("tool-result:{turn}:{cycle}:{id}"),
1390        payload,
1391    }
1392}
1393
1394fn receipt_fact(
1395    prepared: &ToolPreparedV1,
1396    result: ToolExecutionResultV1,
1397) -> Result<NewFact, ActorError> {
1398    result.ensure_prepared(prepared)?;
1399    let ok = result.outcome == ToolExecutionOutcome::Succeeded;
1400    let mut fact = tool_result_fact(
1401        prepared.identity.turn,
1402        prepared.identity.cycle,
1403        &prepared.identity.tool_call_id,
1404        ok,
1405        tool_output_text(&result.output),
1406        !ok,
1407    );
1408    fact.payload["receipt"] =
1409        serde_json::to_value(result).map_err(|error| ActorError::Schema(error.to_string()))?;
1410    Ok(fact)
1411}
1412
1413fn validate_folded_result(
1414    state: &SchedulerState,
1415    payload: &ToolResultPayload,
1416) -> Result<(), ActorError> {
1417    if let Some(receipt) = &payload.receipt {
1418        receipt.validate()?;
1419    }
1420    let Some(call) = &state.pending_call else {
1421        return Err(ActorError::Schema(
1422            "tool_effect_identity_mismatch: result has no pending call".into(),
1423        ));
1424    };
1425    if payload
1426        .tool_call_id
1427        .as_ref()
1428        .is_some_and(|id| id != &call.id)
1429    {
1430        return Err(ActorError::Schema(
1431            "tool_effect_identity_mismatch: wrong pending call".into(),
1432        ));
1433    }
1434    if let Some(receipt) = &payload.receipt {
1435        let prepared = state
1436            .preparations
1437            .iter()
1438            .find(|prepared| prepared.identity == receipt.identity)
1439            .ok_or_else(|| {
1440                ActorError::Schema(
1441                    "tool_effect_identity_mismatch: result has no preparation".into(),
1442                )
1443            })?;
1444        receipt.ensure_prepared(prepared)?;
1445        if receipt.identity.turn != state.turn
1446            || receipt.identity.tool_call_id != call.id
1447            || payload.ok != (receipt.outcome == ToolExecutionOutcome::Succeeded)
1448            || payload.output != tool_output_text(&receipt.output)
1449        {
1450            return Err(ActorError::Schema(
1451                "tool_effect_identity_mismatch: terminal projection differs".into(),
1452            ));
1453        }
1454    } else if state.preparations.iter().any(|prepared| {
1455        prepared.identity.turn == state.turn && prepared.identity.tool_call_id == call.id
1456    }) {
1457        return Err(ActorError::Schema(
1458            "tool_effect_identity_mismatch: prepared call needs a typed result".into(),
1459        ));
1460    }
1461    Ok(())
1462}
1463
1464#[allow(clippy::too_many_arguments)]
1465async fn prepare_tool_batch(
1466    services: Arc<CodingServices>,
1467    batch: Vec<ToolCall>,
1468    messages: &[String],
1469    threshold: Option<u32>,
1470    turn: u64,
1471    cycle: u64,
1472    cancel: CancellationToken,
1473) -> Result<PreparedTransition<CodingServices>, Exit<ActorError>> {
1474    tokio::task::yield_now().await;
1475    let mut prepared_batch = Vec::with_capacity(batch.len());
1476    let mut accepted: Vec<&ToolCall> = Vec::new();
1477    for (index, call) in batch.iter().enumerate() {
1478        let identity = ToolInvocationIdentityV1 {
1479            turn,
1480            cycle,
1481            tool_call_id: call.id.clone(),
1482            batch_index: u32::try_from(index)
1483                .map_err(|_| Exit::Fail(ActorError::Schema("tool batch index overflow".into())))?,
1484            admission: None,
1485        };
1486        let nudge = if threshold.is_some()
1487            && accepted
1488                .iter()
1489                .any(|earlier| earlier.name == call.name && earlier.args == call.args)
1490        {
1491            Some(format!("Tool '{}' has been called 2 times with identical arguments. Change your approach instead of repeating this call.", call.name))
1492        } else {
1493            duplicate_nudge(messages, call, threshold)
1494        };
1495        accepted.push(call);
1496        let mut execution = Arc::clone(&services.tools)
1497            .prepare(
1498                ToolPreparationRequest {
1499                    identity: identity.clone(),
1500                    call: call.clone(),
1501                },
1502                cancel.clone(),
1503            )
1504            .await
1505            .map_err(Exit::Fail)?;
1506        execution.prepared.validate().map_err(Exit::Fail)?;
1507        if !execution.prepared.identity.matches_scheduler(&identity)
1508            || execution.prepared.tool_name != call.name
1509            || execution.prepared.original_args_digest
1510                != tool_args_digest(&call.args).map_err(Exit::Fail)?
1511        {
1512            return Err(Exit::Fail(ActorError::Schema(
1513                "tool_effect_identity_mismatch: runner changed selected intent".into(),
1514            )));
1515        }
1516        if let Some(nudge) = nudge {
1517            // A convergence refusal still binds the same admitted invocation;
1518            // only its execution continuation is replaced by a no-effect result.
1519            let result = ToolExecutionResultV1 {
1520                schema: TOOL_RESULT_SCHEMA.into(),
1521                identity: execution.prepared.identity.clone(),
1522                prepared_digest: execution.prepared.digest().map_err(Exit::Fail)?,
1523                outcome: ToolExecutionOutcome::Denied,
1524                exit_code: None,
1525                output: Value::String(nudge),
1526                effects: ToolEffectObservationV1::complete(Vec::new()),
1527            };
1528            execution.execute = Effect::succeed(result);
1529        }
1530        prepared_batch.push(execution);
1531    }
1532    let facts = prepared_batch
1533        .iter()
1534        .map(|execution| {
1535            Ok(NewFact {
1536                kind: "tool.prepared".into(),
1537                key: format!("tool-prepared:{}", execution.prepared.identity.key()?),
1538                payload: json!({ "prepared": execution.prepared }),
1539            })
1540        })
1541        .collect::<Result<Vec<_>, ActorError>>()
1542        .map_err(Exit::Fail)?;
1543    let parallel = batch.len() > 1 && services.tools.can_parallelize(&batch);
1544    let prepared_batch = Arc::new(prepared_batch);
1545    let run = Effect::from_async_observed(move |services: Arc<CodingServices>, cancel| {
1546        let prepared_batch = Arc::clone(&prepared_batch);
1547        async move {
1548            let results = if parallel {
1549                run_prepared_parallel(&prepared_batch, services, cancel).await
1550            } else {
1551                let mut results = Vec::with_capacity(prepared_batch.len());
1552                for execution in prepared_batch.iter() {
1553                    results.push(
1554                        run_prepared_one(execution, Arc::clone(&services), cancel.clone()).await,
1555                    );
1556                }
1557                results
1558            };
1559            prepared_batch
1560                .iter()
1561                .zip(results)
1562                .map(|(execution, result)| {
1563                    let result = match result {
1564                        Ok(result) => match result.ensure_prepared(&execution.prepared) {
1565                            Ok(()) => result,
1566                            Err(error) => ToolExecutionResultV1::unknown(
1567                                &execution.prepared,
1568                                format!("runner receipt was refused: {error}"),
1569                            )
1570                            .map_err(Exit::Fail)?,
1571                        },
1572                        Err(error) => ToolExecutionResultV1::unknown(
1573                            &execution.prepared,
1574                            format!("execution did not return an authoritative result: {error:?}"),
1575                        )
1576                        .map_err(Exit::Fail)?,
1577                    };
1578                    receipt_fact(&execution.prepared, result).map_err(Exit::Fail)
1579                })
1580                .collect()
1581        }
1582    });
1583    Ok(PreparedTransition { facts, run })
1584}
1585
1586async fn run_prepared_one(
1587    execution: &PreparedToolExecution,
1588    services: Arc<CodingServices>,
1589    cancel: CancellationToken,
1590) -> Result<ToolExecutionResultV1, Exit<ActorError>> {
1591    if cancel.is_cancelled() {
1592        return Ok(ToolExecutionResultV1 {
1593            schema: TOOL_RESULT_SCHEMA.into(),
1594            identity: execution.prepared.identity.clone(),
1595            prepared_digest: execution.prepared.digest().map_err(Exit::Fail)?,
1596            outcome: ToolExecutionOutcome::Cancelled,
1597            exit_code: None,
1598            output: Value::String("cancelled".into()),
1599            effects: ToolEffectObservationV1::complete(Vec::new()),
1600        });
1601    }
1602    // Once execution started, its returned host evidence survives a late cancel.
1603    execution
1604        .execute
1605        .clone()
1606        .run_with_cancel(services, cancel)
1607        .await
1608        .0
1609}
1610
1611async fn run_prepared_parallel(
1612    executions: &[PreparedToolExecution],
1613    services: Arc<CodingServices>,
1614    cancel: CancellationToken,
1615) -> Vec<Result<ToolExecutionResultV1, Exit<ActorError>>> {
1616    let mut set = tokio::task::JoinSet::new();
1617    let mut ids = std::collections::HashMap::new();
1618    for (index, execution) in executions.iter().cloned().enumerate() {
1619        let services = Arc::clone(&services);
1620        let cancel = cancel.clone();
1621        let task = set.spawn(async move { run_prepared_one(&execution, services, cancel).await });
1622        ids.insert(task.id(), index);
1623    }
1624    let mut results: Vec<_> = executions.iter().map(|_| None).collect();
1625    while let Some(joined) = set.join_next_with_id().await {
1626        match joined {
1627            Ok((id, result)) => {
1628                if let Some(index) = ids.remove(&id) {
1629                    results[index] = Some(result);
1630                }
1631            }
1632            Err(error) => {
1633                if let Some(index) = ids.remove(&error.id()) {
1634                    results[index] = Some(Err(Exit::Die(error.to_string())));
1635                }
1636            }
1637        }
1638    }
1639    results
1640        .into_iter()
1641        .map(|result| result.unwrap_or_else(|| Err(Exit::Die("tool batch lost a result".into()))))
1642        .collect()
1643}
1644
1645// The existing batch overlap unit tests exercise the executor directly.
1646#[cfg(test)]
1647#[allow(clippy::too_many_arguments)]
1648async fn run_tool_batch(
1649    services: Arc<CodingServices>,
1650    batch: Vec<ToolCall>,
1651    messages: &[String],
1652    threshold: Option<u32>,
1653    turn: u64,
1654    cycle: u64,
1655    cancel: CancellationToken,
1656) -> Result<Vec<NewFact>, Exit<ActorError>> {
1657    let prepared = prepare_tool_batch(
1658        Arc::clone(&services),
1659        batch,
1660        messages,
1661        threshold,
1662        turn,
1663        cycle,
1664        cancel.clone(),
1665    )
1666    .await?;
1667    prepared.run.run_with_cancel(services, cancel).await.0
1668}
1669
1670/// Infer-loop scheduler (model / tool / compact / budget deny phases).
1671pub fn coding_scheduler(config: HarnessConfig) -> ErasedComponent<CodingServices, CodingView> {
1672    let step_config = config.clone();
1673    let output_config = config;
1674    component(
1675        SchedulerState::new,
1676        move |state, fact| step(state, fact, &step_config),
1677        move |state| (view_of(state), transitions_of(state, &output_config)),
1678    )
1679}
1680
1681/// Stock Meta Harness: `system + tools + budget + compact + infer([scheduler])`.
1682///
1683/// Equivalent to the pre-composition three-component actor for control
1684/// behavior; budget/compact also appear as explicit tree parts.
1685pub fn coding_actor(config: HarnessConfig) -> Actor<CodingServices, CodingView> {
1686    let spec = crate::compose::MetaHarnessSpec {
1687        name: "a3s-code",
1688        budget: config.budget(),
1689        compact_after_chars: config.compact_after_chars(),
1690        step_limit: config.step_limit(),
1691        model_attempts: config.model_attempts(),
1692        system: config.system().to_vec(),
1693        tools: config.tools().to_vec(),
1694        tool_round_cap: config.tool_round_cap(),
1695        parts: Vec::new(),
1696    };
1697    crate::compose::HarnessGraph::from_spec(spec, || coding_scheduler(config.clone())).into_actor()
1698}
1699
1700pub fn message_fact(key: impl Into<String>, text: impl Into<String>) -> NewFact {
1701    NewFact {
1702        kind: "user.message".into(),
1703        key: key.into(),
1704        payload: json!({ "text": text.into() }),
1705    }
1706}
1707
1708pub fn confirm_fact(
1709    key: impl Into<String>,
1710    tool_call_id: impl Into<String>,
1711    approved: bool,
1712) -> NewFact {
1713    NewFact {
1714        kind: "confirmation.answered".into(),
1715        key: key.into(),
1716        payload: json!({ "tool_call_id": tool_call_id.into(), "approved": approved }),
1717    }
1718}
1719
1720pub fn answer_fact(key: impl Into<String>, text: impl Into<String>) -> NewFact {
1721    NewFact {
1722        kind: "question.answered".into(),
1723        key: key.into(),
1724        payload: json!({ "text": text.into() }),
1725    }
1726}
1727
1728/// One background-task output line as a fact on the session's one log.
1729///
1730/// `task_seq` counts emitted facts within the task and rides the fact KEY
1731/// (`bg:{name}:{task_seq}`), which is what makes keys unique per task
1732/// without coordinating with the log's own seq. `event` is
1733/// `Some("truncated" | "exited")` for the stream-cap marker and the final
1734/// disposition line; `stream` is `Some("stderr")` for standard-error lines.
1735pub fn background_output_fact(
1736    name: &str,
1737    line: &str,
1738    task_seq: u64,
1739    event: Option<&str>,
1740    stream: Option<&str>,
1741) -> NewFact {
1742    let mut payload = json!({ "name": name, "line": line });
1743    if let Some(event) = event {
1744        payload["event"] = json!(event);
1745    }
1746    if let Some(stream) = stream {
1747        payload["stream"] = json!(stream);
1748    }
1749    NewFact {
1750        kind: "background.output".into(),
1751        key: format!("bg:{name}:{task_seq}"),
1752        payload,
1753    }
1754}
1755
1756pub async fn ingest_coding(
1757    actor: &Actor<CodingServices, CodingView>,
1758    log: &dyn crate::log::LogStore,
1759    services: Arc<CodingServices>,
1760    thread_id: &str,
1761    fact: NewFact,
1762    limit: u32,
1763) -> Result<Settlement<CodingView>, Exit<ActorError>> {
1764    ingest_coding_with_cancel(
1765        actor,
1766        log,
1767        services,
1768        thread_id,
1769        fact,
1770        limit,
1771        &CancellationToken::new(),
1772    )
1773    .await
1774}
1775
1776pub async fn ingest_coding_with_cancel(
1777    actor: &Actor<CodingServices, CodingView>,
1778    log: &dyn crate::log::LogStore,
1779    services: Arc<CodingServices>,
1780    thread_id: &str,
1781    fact: NewFact,
1782    limit: u32,
1783    cancel: &CancellationToken,
1784) -> Result<Settlement<CodingView>, Exit<ActorError>> {
1785    ingest_with_cancel(actor, log, services, thread_id, fact, limit, cancel).await
1786}
1787
1788pub async fn resume_coding(
1789    actor: &Actor<CodingServices, CodingView>,
1790    log: &dyn crate::log::LogStore,
1791    services: Arc<CodingServices>,
1792    thread_id: &str,
1793    limit: u32,
1794) -> Result<Settlement<CodingView>, Exit<ActorError>> {
1795    resume_coding_with_cancel(
1796        actor,
1797        log,
1798        services,
1799        thread_id,
1800        limit,
1801        &CancellationToken::new(),
1802    )
1803    .await
1804}
1805
1806pub async fn resume_coding_with_cancel(
1807    actor: &Actor<CodingServices, CodingView>,
1808    log: &dyn crate::log::LogStore,
1809    services: Arc<CodingServices>,
1810    thread_id: &str,
1811    limit: u32,
1812    cancel: &CancellationToken,
1813) -> Result<Settlement<CodingView>, Exit<ActorError>> {
1814    resume_with_cancel(actor, log, services, thread_id, limit, cancel).await
1815}
1816
1817#[cfg(test)]
1818mod batch_overlap_tests {
1819    //! WC-MEAS2: deterministic overlap oracles for the tool-batch executor.
1820    //! A batch the adapter marks parallel-safe must actually overlap — a
1821    //! serialization regression deadlocks on the barrier and fails the
1822    //! guarded timeout instead of passing silently (matrix #1's silent
1823    //! failure mode, pinned at the executor this time).
1824
1825    use super::*;
1826    use serde_json::json;
1827    use std::sync::atomic::{AtomicUsize, Ordering};
1828    use std::sync::Arc;
1829    use std::time::{Duration, Instant};
1830    use tokio::sync::Barrier;
1831
1832    struct NeverModel;
1833
1834    impl Completion for NeverModel {
1835        fn complete(
1836            &self,
1837            _request: CompletionRequest,
1838            _cancel: CancellationToken,
1839        ) -> BoxFuture<Result<ModelDecision, ActorError>> {
1840            Box::pin(async {
1841                Ok(ModelDecision::Text {
1842                    text: "unused".into(),
1843                })
1844            })
1845        }
1846    }
1847
1848    struct NeverCompact;
1849
1850    impl Compactor for NeverCompact {
1851        fn compact(&self, _messages: &[String]) -> BoxFuture<Result<String, ActorError>> {
1852            Box::pin(async { Ok(String::new()) })
1853        }
1854    }
1855
1856    struct BarrierInner {
1857        barrier: Barrier,
1858        entered: AtomicUsize,
1859    }
1860
1861    struct BarrierTools {
1862        inner: Arc<BarrierInner>,
1863        allow_parallel: bool,
1864    }
1865
1866    impl ToolRunner for BarrierTools {
1867        fn run(
1868            &self,
1869            call: ToolCall,
1870            _cancel: CancellationToken,
1871        ) -> BoxFuture<Result<Value, ActorError>> {
1872            let inner = Arc::clone(&self.inner);
1873            Box::pin(async move {
1874                inner.entered.fetch_add(1, Ordering::SeqCst);
1875                // Releases only when every party is in flight at once.
1876                let _ = inner.barrier.wait().await;
1877                Ok(json!(format!("passed-{}", call.id)))
1878            })
1879        }
1880
1881        fn can_parallelize(&self, _calls: &[ToolCall]) -> bool {
1882            self.allow_parallel
1883        }
1884    }
1885
1886    struct SleepTools {
1887        per_call: Duration,
1888        allow_parallel: bool,
1889    }
1890
1891    impl ToolRunner for SleepTools {
1892        fn run(
1893            &self,
1894            call: ToolCall,
1895            _cancel: CancellationToken,
1896        ) -> BoxFuture<Result<Value, ActorError>> {
1897            let per_call = self.per_call;
1898            Box::pin(async move {
1899                tokio::time::sleep(per_call).await;
1900                Ok(json!(format!("slept-{}", call.id)))
1901            })
1902        }
1903
1904        fn can_parallelize(&self, _calls: &[ToolCall]) -> bool {
1905            self.allow_parallel
1906        }
1907    }
1908
1909    fn services(tools: Arc<dyn ToolRunner>) -> Arc<CodingServices> {
1910        Arc::new(CodingServices {
1911            completion: Arc::new(NeverModel),
1912            tools,
1913            compactor: Arc::new(NeverCompact),
1914        })
1915    }
1916
1917    fn read_batch(count: usize) -> Vec<ToolCall> {
1918        (0..count)
1919            .map(|index| ToolCall {
1920                id: format!("r{index}"),
1921                name: "read".into(),
1922                args: json!({ "file_path": format!("f{index}.rs") }),
1923                needs_confirmation: false,
1924                text: None,
1925                reasoning: None,
1926            })
1927            .collect()
1928    }
1929
1930    #[tokio::test]
1931    async fn parallel_batch_overlaps_or_fails_closed() {
1932        let parties = 4;
1933        let tools = Arc::new(BarrierTools {
1934            inner: Arc::new(BarrierInner {
1935                barrier: Barrier::new(parties),
1936                entered: AtomicUsize::new(0),
1937            }),
1938            allow_parallel: true,
1939        });
1940        let entered = Arc::clone(&tools.inner);
1941        let facts = tokio::time::timeout(
1942            Duration::from_secs(10),
1943            run_tool_batch(
1944                services(tools),
1945                read_batch(parties),
1946                &[],
1947                None,
1948                1,
1949                1,
1950                CancellationToken::new(),
1951            ),
1952        )
1953        .await
1954        .expect("SERIALIZED: the batch never overlapped — the executor ran calls one after another while the adapter marked them parallel-safe")
1955        .expect("batch executor failed");
1956        assert_eq!(facts.len(), parties);
1957        for (fact, index) in facts.iter().zip(0..) {
1958            assert_eq!(fact.kind, "tool.result");
1959            assert_eq!(fact.key, format!("tool-result:1:1:r{index}"));
1960            assert_eq!(fact.payload["output"], json!(format!("passed-r{index}")));
1961            assert_eq!(fact.payload["ok"], json!(true));
1962        }
1963        assert_eq!(
1964            entered.entered.load(Ordering::SeqCst),
1965            parties,
1966            "every party must have entered the barrier"
1967        );
1968    }
1969
1970    #[tokio::test]
1971    async fn duplicate_call_twins_stay_parked_even_in_parallel_batches() {
1972        // The same call emitted twice must not run twice; the twin gets the
1973        // nudge fact while the two distinct calls still overlap. Only the
1974        // distinct calls are runnable, so the barrier counts those.
1975        let parties = 2;
1976        let tools = Arc::new(BarrierTools {
1977            inner: Arc::new(BarrierInner {
1978                barrier: Barrier::new(parties),
1979                entered: AtomicUsize::new(0),
1980            }),
1981            allow_parallel: true,
1982        });
1983        let mut batch = read_batch(parties);
1984        batch.push(batch[0].clone());
1985        let facts = tokio::time::timeout(
1986            Duration::from_secs(10),
1987            run_tool_batch(
1988                services(tools),
1989                batch,
1990                &[],
1991                Some(1),
1992                1,
1993                1,
1994                CancellationToken::new(),
1995            ),
1996        )
1997        .await
1998        .expect("SERIALIZED: distinct calls must overlap")
1999        .expect("batch executor failed");
2000        assert_eq!(facts.len(), 3);
2001        assert_eq!(facts[0].payload["ok"], json!(true));
2002        assert_eq!(facts[1].payload["ok"], json!(true));
2003        assert_eq!(facts[2].payload["ok"], json!(false));
2004        assert_eq!(
2005            facts[2].payload["countRun"],
2006            json!(true),
2007            "the parked twin still counts toward the budget"
2008        );
2009    }
2010
2011    // Measurement, not assertion: the oracle above pins overlap
2012    // deterministically; this records the fan-out wall clock the evidence
2013    // ledger quotes (batch=8 disjoint reads shape, sleep=60ms stand-in).
2014    #[tokio::test]
2015    async fn parallel_batch_fanout_wall_clock_is_recorded() {
2016        let per_call = Duration::from_millis(60);
2017        let parallel_tools = Arc::new(SleepTools {
2018            per_call,
2019            allow_parallel: true,
2020        });
2021        let serial_tools = Arc::new(SleepTools {
2022            per_call,
2023            allow_parallel: false,
2024        });
2025        let batch = read_batch(8);
2026
2027        let started = Instant::now();
2028        run_tool_batch(
2029            services(parallel_tools),
2030            batch.clone(),
2031            &[],
2032            None,
2033            1,
2034            1,
2035            CancellationToken::new(),
2036        )
2037        .await
2038        .expect("parallel arm failed");
2039        let parallel_ms = started.elapsed().as_millis();
2040
2041        let started = Instant::now();
2042        run_tool_batch(
2043            services(serial_tools),
2044            batch,
2045            &[],
2046            None,
2047            1,
2048            1,
2049            CancellationToken::new(),
2050        )
2051        .await
2052        .expect("serial arm failed");
2053        let serial_ms = started.elapsed().as_millis();
2054
2055        println!(
2056            "WCMEAS2 fan-out: batch=8 per_call=60ms parallel={parallel_ms}ms serial={serial_ms}ms"
2057        );
2058    }
2059}
2060
2061#[cfg(test)]
2062mod background_fold_tests {
2063    //! WC-BG1 fold semantics: background arrivals are facts on the one log,
2064    //! and the fold places them where the next model call reads them — never
2065    //! between an `assistant\ntool` line and its results, never before the
2066    //! `model.turn` of a call that already ran without them.
2067
2068    use super::*;
2069
2070    fn config() -> HarnessConfig {
2071        HarnessConfig::new(16, 1_000_000, 8, 1, Vec::new(), Vec::new()).unwrap()
2072    }
2073
2074    fn fact(seq: u64, new_fact: NewFact) -> Fact {
2075        Fact {
2076            seq,
2077            kind: new_fact.kind,
2078            key: new_fact.key,
2079            cause: None,
2080            payload: new_fact.payload,
2081        }
2082    }
2083
2084    fn bg(seq: u64, name: &str, line: &str, task_seq: u64) -> Fact {
2085        fact(
2086            seq,
2087            background_output_fact(name, line, task_seq, None, None),
2088        )
2089    }
2090
2091    fn tool_call_fact(seq: u64, name: &str, id: &str) -> Fact {
2092        fact(
2093            seq,
2094            NewFact {
2095                kind: "model.turn".into(),
2096                key: "model:1:0".into(),
2097                payload: serde_json::to_value(ModelDecision::Tool {
2098                    call: ToolCall {
2099                        id: id.into(),
2100                        needs_confirmation: false,
2101                        name: name.into(),
2102                        args: serde_json::json!({}),
2103                        text: None,
2104                        reasoning: None,
2105                    },
2106                    also: Vec::new(),
2107                })
2108                .unwrap(),
2109            },
2110        )
2111    }
2112
2113    fn tool_result_fact(seq: u64) -> Fact {
2114        fact(
2115            seq,
2116            NewFact {
2117                kind: "tool.result".into(),
2118                key: "tr".into(),
2119                payload: serde_json::json!({ "ok": true, "output": "ok" }),
2120            },
2121        )
2122    }
2123
2124    fn fold(facts: Vec<Fact>) -> SchedulerState {
2125        let mut state = SchedulerState::new();
2126        for item in facts {
2127            state = step(state, &item, &config());
2128        }
2129        state
2130    }
2131
2132    #[test]
2133    fn arrival_during_tool_batch_lands_after_its_result() {
2134        let state = fold(vec![
2135            fact(1, message_fact("m-1", "start the watch")),
2136            tool_call_fact(2, "background", "call-1"),
2137            bg(3, "watch", "BUILD DONE", 1),
2138            tool_result_fact(4),
2139        ]);
2140        let tool_at = state
2141            .messages
2142            .iter()
2143            .position(|line| line.starts_with("assistant\ntool background"))
2144            .expect("assistant tool line present");
2145        let result_at = state
2146            .messages
2147            .iter()
2148            .position(|line| line.starts_with("tool\n"))
2149            .expect("tool result present");
2150        let bg_at = state
2151            .messages
2152            .iter()
2153            .position(|line| line == "[background:watch] BUILD DONE")
2154            .expect("arrival folded");
2155        // Protocol adjacency: the tool result directly follows its call, and
2156        // the arrival reads after the completed pair.
2157        assert_eq!(result_at, tool_at + 1, "tool result must be adjacent");
2158        assert!(bg_at > result_at, "arrival must follow the completed pair");
2159        assert!(state.background_pending.is_empty());
2160    }
2161
2162    #[test]
2163    fn arrival_during_model_call_waits_for_the_next_completed_step() {
2164        // The arrival fact has a lower seq than the model.turn of the call
2165        // that was in flight, so it must not appear before that turn's line.
2166        let state = fold(vec![
2167            fact(1, message_fact("m-1", "watch")),
2168            bg(2, "watch", "early", 1),
2169            fact(
2170                3,
2171                NewFact {
2172                    kind: "model.turn".into(),
2173                    key: "model:1:0".into(),
2174                    payload: serde_json::to_value(ModelDecision::Text {
2175                        text: "answer without seeing it".into(),
2176                    })
2177                    .unwrap(),
2178                },
2179            ),
2180        ]);
2181        assert!(
2182            !state
2183                .messages
2184                .iter()
2185                .any(|line| line == "[background:watch] early"),
2186            "the in-flight call did not see the arrival; the fold must not claim it did"
2187        );
2188        assert_eq!(state.background_pending, vec!["[background:watch] early"]);
2189        // The next user input flushes the buffered line before itself.
2190        let state = step(state, &fact(4, message_fact("m-2", "next turn")), &config());
2191        let flushed_at = state
2192            .messages
2193            .iter()
2194            .position(|line| line == "[background:watch] early")
2195            .expect("flushed");
2196        let user_at = state
2197            .messages
2198            .iter()
2199            .position(|line| line == "user\nnext turn")
2200            .expect("new user line");
2201        assert!(flushed_at < user_at);
2202        assert!(state.background_pending.is_empty());
2203    }
2204
2205    #[test]
2206    fn arrival_while_parked_lands_directly() {
2207        let state = fold(vec![
2208            fact(1, message_fact("m-1", "watch")),
2209            fact(
2210                2,
2211                NewFact {
2212                    kind: "model.turn".into(),
2213                    key: "model:1:0".into(),
2214                    payload: serde_json::to_value(ModelDecision::Text {
2215                        text: "done asking".into(),
2216                    })
2217                    .unwrap(),
2218                },
2219            ),
2220            bg(3, "watch", "late", 1),
2221        ]);
2222        assert_eq!(state.messages.last().unwrap(), "[background:watch] late");
2223        assert!(state.background_pending.is_empty());
2224    }
2225
2226    #[test]
2227    fn pending_cap_records_one_truncation_marker() {
2228        let mut facts = vec![
2229            fact(1, message_fact("m-1", "watch")),
2230            tool_call_fact(2, "background", "call-1"),
2231        ];
2232        for index in 0..(BACKGROUND_PENDING_CAP + 10) {
2233            facts.push(bg(
2234                3 + index as u64,
2235                "watch",
2236                &format!("line-{index}"),
2237                index as u64 + 1,
2238            ));
2239        }
2240        facts.push(tool_result_fact(3 + (BACKGROUND_PENDING_CAP + 10) as u64));
2241        let state = fold(facts);
2242        let markers = state
2243            .messages
2244            .iter()
2245            .filter(|line| line.as_str() == BACKGROUND_TRUNCATION_LINE)
2246            .count();
2247        assert_eq!(markers, 1, "exactly one truncation marker");
2248        assert!(
2249            !state.messages.iter().any(|line| line.contains("line-70")),
2250            "overflow arrivals are dropped, not buffered unbounded"
2251        );
2252        assert!(state.messages.iter().any(|line| line.contains("line-0")));
2253        assert!(state.background_pending.is_empty());
2254    }
2255
2256    #[test]
2257    fn exited_and_stderr_events_render_dispositions() {
2258        let state = fold(vec![
2259            fact(
2260                1,
2261                background_output_fact("watch", "exit status: 0", 9, Some("exited"), None),
2262            ),
2263            fact(
2264                2,
2265                background_output_fact("watch", "boom", 10, None, Some("stderr")),
2266            ),
2267        ]);
2268        assert!(state
2269            .messages
2270            .iter()
2271            .any(|line| line == "[background:watch] exited: exit status: 0"));
2272        assert!(state
2273            .messages
2274            .iter()
2275            .any(|line| line == "[background:watch] (stderr) boom"));
2276    }
2277
2278    #[test]
2279    fn malformed_background_fact_fails_the_fold() {
2280        let state = fold(vec![Fact {
2281            seq: 1,
2282            kind: "background.output".into(),
2283            key: "bg:watch:1".into(),
2284            cause: None,
2285            payload: serde_json::json!({ "unexpected": true }),
2286        }]);
2287        assert!(state.schema_error.is_some());
2288        assert_eq!(state.phase, CodingPhase::Done);
2289    }
2290}
2291
2292#[cfg(test)]
2293mod conversation_history_tests;