Skip to main content

kranz_engine/
backend_mock.rs

1//! Mock agent backend — scripted sessions so the whole engine tests without
2//! model calls (plan §8: "the seam").
3//!
4//! [`MockBackend`] holds a FIFO queue of [`MockScript`]s; each
5//! [`AgentBackend::start`] call consumes the front script and returns a
6//! [`MockSession`] that replays the scripted [`AgentEvent`]s. The backend
7//! records every [`SessionSpec`] it was started with and every user message
8//! injected into its sessions, so tests can assert on exactly what the engine
9//! asked for.
10//!
11//! Blocking semantics mirror the real CLI backend: a single-shot session's
12//! stream closes after its scripted events; a streaming session with an empty
13//! queue parks in [`AgentSession::next_event`] until `send_user_message`
14//! supplies the next scripted batch or `abort` closes the stream. Because the
15//! `AgentSession` trait takes `&mut self`, a parked `next_event` future must
16//! be cancelled (e.g. via `tokio::time::timeout` or `select!`) before the
17//! caller can send or abort — the same discipline a real child-process stream
18//! requires.
19
20use crate::backend::{AgentBackend, AgentEvent, AgentSession, SessionExit, SessionSpec};
21use crate::error::{EngineError, Result};
22use crate::types::TokenUsage;
23use serde_json::json;
24use std::collections::VecDeque;
25use std::sync::{Arc, Mutex};
26use tokio::sync::{watch, Notify};
27
28// ---------------------------------------------------------------------------
29// Script
30// ---------------------------------------------------------------------------
31
32/// One scripted agent session.
33#[derive(Debug, Clone)]
34pub struct MockScript {
35    /// Yielded in order by `next_event`.
36    pub events: Vec<AgentEvent>,
37    /// Each `send_user_message` appends the next batch to the pending queue
38    /// (streaming sessions).
39    pub on_message: VecDeque<Vec<AgentEvent>>,
40    /// Exit reported once the stream closes (ignored when aborted, and never
41    /// reached by streaming sessions, which only end via `abort`).
42    pub exit: SessionExit,
43    /// When false, `send_user_message` errors (single-shot session).
44    pub streaming: bool,
45    /// Session id override; defaults to `spec.session_id`.
46    pub session_id: Option<String>,
47    /// Rendezvous: withhold this script's FINAL scripted event (for a
48    /// single-shot run, its `Result`) until at least this many sessions have
49    /// been started backend-wide. Makes wall-clock-overlap tests
50    /// deterministic: a held session provably cannot finish before its peers
51    /// start, so peak-concurrency assertions are exact — and if the engine
52    /// ever dispatches sequentially, the rendezvous deadlocks into the
53    /// test's timeout instead of flaky-passing. Meaningful for single-shot
54    /// scripts (streaming sessions end via `abort`, not a final event).
55    pub hold_result_until_started: Option<usize>,
56    /// Files to write into the session's working directory (`spec.cwd`) when
57    /// the session starts — the seam that lets a mock session leave a dirty
58    /// tree for the engine's §4.4 checkpoint to commit. Each entry is a path
59    /// relative to the session cwd and its contents; parent directories are
60    /// created as needed. Empty by default (byte-for-byte unchanged
61    /// behaviour).
62    pub writes: Vec<(String, String)>,
63    /// Commit messages to record in the session's working directory
64    /// (`spec.cwd`) when the session starts, applied AFTER `writes`: each
65    /// becomes `git add -A && git commit -m <message>` — the seam that lets a
66    /// scripted validator MOVE HEAD, as a real `git commit`-capable validator
67    /// could (validator-immutability-proof tests). Empty by default.
68    pub commits: Vec<String>,
69    /// Paths to REMOVE from the session's working directory (`spec.cwd`) when
70    /// the session starts, applied AFTER `writes` and `commits` — the seam
71    /// that lets a scripted worker sabotage its own worktree metadata (e.g.
72    /// delete its `.git`), so the engine's checkpoint finds an uninspectable
73    /// worktree exactly as a hostile worker could leave one (12th-pass
74    /// review, candidate-inspection-failure tests). Empty by default.
75    pub removes: Vec<String>,
76    /// CONNECTs this session issues through the egress proxy named by
77    /// `spec.env[HTTPS_PROXY]` at start (3.3b): the seam that lets a scripted
78    /// `fs+net` session be REFUSED a destination so the run's outcome carries
79    /// `denied_egress`, exactly as a real sandboxed agent's blocked connection
80    /// would. Each `(host, port)` is attempted in order before the session's
81    /// events replay. Empty by default (no proxy traffic).
82    pub proxy_connects: Vec<(String, u16)>,
83}
84
85impl Default for MockScript {
86    fn default() -> Self {
87        MockScript {
88            events: Vec::new(),
89            on_message: VecDeque::new(),
90            exit: SessionExit::Completed,
91            streaming: false,
92            session_id: None,
93            hold_result_until_started: None,
94            writes: Vec::new(),
95            commits: Vec::new(),
96            removes: Vec::new(),
97            proxy_connects: Vec::new(),
98        }
99    }
100}
101
102impl MockScript {
103    /// A completed single-shot run: init → text → successful result carrying
104    /// `final_text`, with the standard mock usage/cost.
105    pub fn single_shot(final_text: &str) -> Self {
106        MockScript {
107            events: vec![
108                mock_init("mock-session"),
109                mock_text(final_text),
110                mock_result_text(final_text),
111            ],
112            ..Default::default()
113        }
114    }
115
116    /// Like [`single_shot`](Self::single_shot) but the text/result carry the
117    /// serialized JSON — for worker-report / validator-report runs where the
118    /// engine parses the result text.
119    pub fn single_shot_json(value: &serde_json::Value) -> Self {
120        let text = value.to_string();
121        MockScript {
122            events: vec![
123                mock_init("mock-session"),
124                mock_text(&text),
125                mock_result_json(value),
126            ],
127            ..Default::default()
128        }
129    }
130
131    /// A streaming-input session that yields `initial` and then waits for
132    /// injected user messages. Chain [`responding`](Self::responding) to
133    /// script the per-message batches.
134    pub fn streaming(initial: Vec<AgentEvent>) -> Self {
135        MockScript {
136            events: initial,
137            streaming: true,
138            ..Default::default()
139        }
140    }
141
142    /// Script the batches released by successive `send_user_message` calls.
143    pub fn responding(mut self, batches: Vec<Vec<AgentEvent>>) -> Self {
144        self.on_message = batches.into();
145        self
146    }
147
148    /// Override the exit reported when the stream closes.
149    pub fn with_exit(mut self, exit: SessionExit) -> Self {
150        self.exit = exit;
151        self
152    }
153
154    /// Override the session id reported by [`AgentSession::session_id`]
155    /// (defaults to `spec.session_id`).
156    pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
157        self.session_id = Some(session_id.into());
158        self
159    }
160
161    /// Rendezvous (see [`MockScript::hold_result_until_started`]): withhold
162    /// the final scripted event until `n` sessions have started.
163    pub fn rendezvous(mut self, n: usize) -> Self {
164        self.hold_result_until_started = Some(n);
165        self
166    }
167
168    /// Write `contents` to `path` (relative to the session's working
169    /// directory) when the session starts — lets a scripted worker session
170    /// leave a dirty tree for the engine's §4.4 checkpoint to commit.
171    pub fn writes_file(mut self, path: impl Into<String>, contents: impl Into<String>) -> Self {
172        self.writes.push((path.into(), contents.into()));
173        self
174    }
175
176    /// `git add -A && git commit -m <message>` in the session's working
177    /// directory when the session starts (applied after any `writes_file`)
178    /// — lets a scripted validator move HEAD inside its "read-only" session,
179    /// as a real Bash-capable validator could (validator-immutability-proof
180    /// tests).
181    pub fn commits_all(mut self, message: impl Into<String>) -> Self {
182        self.commits.push(message.into());
183        self
184    }
185
186    /// Remove `path` (relative to the session's working directory) when the
187    /// session starts, applied after any `writes_file`/`commits_all` — lets
188    /// a scripted worker sabotage its own worktree (e.g. delete its `.git`)
189    /// so the engine's checkpoint faces an uninspectable worktree
190    /// (12th-pass review, candidate-inspection-failure tests).
191    pub fn removes_path(mut self, path: impl Into<String>) -> Self {
192        self.removes.push(path.into());
193        self
194    }
195
196    /// Attempt `CONNECT host:port` through the session's egress proxy (the
197    /// `HTTPS_PROXY` env the runner wires for an `fs+net` session) when the
198    /// session starts — the stand-in for a real sandboxed agent's blocked
199    /// connection, so the run's outcome carries the denial the grant flow
200    /// parks on. Errors the start (loud, never vacuous) when the spec carries
201    /// no proxy env.
202    pub fn connects_via_proxy(mut self, host: impl Into<String>, port: u16) -> Self {
203        self.proxy_connects.push((host.into(), port));
204        self
205    }
206}
207
208// ---------------------------------------------------------------------------
209// Event helpers — the standard shapes scripts are built from
210// ---------------------------------------------------------------------------
211
212/// The token usage attached to every mock result event.
213fn mock_usage() -> TokenUsage {
214    TokenUsage {
215        input: 1000,
216        output: 200,
217        cache_read: 0,
218        cache_write: 0,
219    }
220}
221
222/// System init event (first message of every session).
223pub fn mock_init(session_id: &str) -> AgentEvent {
224    AgentEvent::Init {
225        session_id: session_id.to_string(),
226        model: "mock-model".to_string(),
227        raw: json!({
228            "mock": true,
229            "type": "system",
230            "subtype": "init",
231            "session_id": session_id,
232            "model": "mock-model",
233        }),
234    }
235}
236
237/// Assistant text output.
238pub fn mock_text(text: &str) -> AgentEvent {
239    AgentEvent::Text {
240        text: text.to_string(),
241        raw: json!({
242            "mock": true,
243            "type": "assistant",
244            "message": { "content": [{ "type": "text", "text": text }] },
245        }),
246    }
247}
248
249/// Assistant tool request.
250pub fn mock_tool_use(tool: &str, summary: &str) -> AgentEvent {
251    AgentEvent::ToolUse {
252        tool: tool.to_string(),
253        summary: summary.to_string(),
254        raw: json!({
255            "mock": true,
256            "type": "assistant",
257            "message": {
258                "content": [{ "type": "tool_use", "name": tool, "input": { "summary": summary } }],
259            },
260        }),
261    }
262}
263
264/// Successful tool result returned to the model.
265pub fn mock_tool_result(tool: &str, summary: &str) -> AgentEvent {
266    AgentEvent::ToolResult {
267        tool: Some(tool.to_string()),
268        denied: false,
269        summary: summary.to_string(),
270        raw: json!({
271            "mock": true,
272            "type": "user",
273            "tool": tool,
274            "content": summary,
275            "is_error": false,
276        }),
277    }
278}
279
280/// Tool call blocked by permission rules (guardrail hit, §4.7).
281pub fn mock_denied(tool: &str, summary: &str) -> AgentEvent {
282    AgentEvent::ToolResult {
283        tool: Some(tool.to_string()),
284        denied: true,
285        summary: summary.to_string(),
286        raw: json!({
287            "mock": true,
288            "type": "user",
289            "tool": tool,
290            "content": summary,
291            "is_error": true,
292            "denied": true,
293        }),
294    }
295}
296
297/// Successful terminal result with `text`, standard mock usage and cost 0.01.
298pub fn mock_result_text(text: &str) -> AgentEvent {
299    AgentEvent::Result {
300        text: text.to_string(),
301        is_error: false,
302        usage: mock_usage(),
303        cost_usd: Some(0.01),
304        num_turns: Some(1),
305        raw: json!({
306            "mock": true,
307            "type": "result",
308            "subtype": "success",
309            "is_error": false,
310            "result": text,
311            "total_cost_usd": 0.01,
312            "num_turns": 1,
313            "usage": {
314                "input_tokens": 1000,
315                "output_tokens": 200,
316                "cache_read_input_tokens": 0,
317                "cache_creation_input_tokens": 0,
318            },
319        }),
320    }
321}
322
323/// Terminal error result: same shape as [`mock_result_text`] but `is_error`
324/// is set, so `pump_turn` treats the turn as failed (best-effort capture-turn
325/// abort scripting).
326pub fn mock_result_error(text: &str) -> AgentEvent {
327    match mock_result_text(text) {
328        AgentEvent::Result {
329            usage,
330            cost_usd,
331            num_turns,
332            ..
333        } => AgentEvent::Result {
334            text: text.to_string(),
335            is_error: true,
336            usage,
337            cost_usd,
338            num_turns,
339            raw: json!({
340                "mock": true,
341                "type": "result",
342                "subtype": "error",
343                "is_error": true,
344                "result": text,
345                "total_cost_usd": 0.01,
346                "num_turns": 1,
347                "usage": {
348                    "input_tokens": 1000,
349                    "output_tokens": 200,
350                    "cache_read_input_tokens": 0,
351                    "cache_creation_input_tokens": 0,
352                },
353            }),
354        },
355        _ => unreachable!("mock_result_text always builds a Result event"),
356    }
357}
358
359/// Like [`mock_result_text`] but the result text is the serialized JSON value
360/// (structured-output runs).
361pub fn mock_result_json(value: &serde_json::Value) -> AgentEvent {
362    let text = value.to_string();
363    match mock_result_text(&text) {
364        AgentEvent::Result {
365            is_error,
366            usage,
367            cost_usd,
368            num_turns,
369            ..
370        } => AgentEvent::Result {
371            text,
372            is_error,
373            usage,
374            cost_usd,
375            num_turns,
376            raw: json!({
377                "mock": true,
378                "type": "result",
379                "subtype": "success",
380                "is_error": false,
381                "result": value.to_string(),
382                "structured_output": value,
383                "total_cost_usd": 0.01,
384                "num_turns": 1,
385                "usage": {
386                    "input_tokens": 1000,
387                    "output_tokens": 200,
388                    "cache_read_input_tokens": 0,
389                    "cache_creation_input_tokens": 0,
390                },
391            }),
392        },
393        _ => unreachable!("mock_result_text always builds a Result event"),
394    }
395}
396
397// ---------------------------------------------------------------------------
398// Backend
399// ---------------------------------------------------------------------------
400
401/// Scripted [`AgentBackend`]: `start()` pops scripts FIFO and records every
402/// spec it saw. Injected user messages are recorded per session, aligned with
403/// start order.
404pub struct MockBackend {
405    scripts: Mutex<VecDeque<MockScript>>,
406    started_specs: Mutex<Vec<SessionSpec>>,
407    /// Shared with sessions: `injected[i]` are the messages injected into the
408    /// i-th started session.
409    injected: Arc<Mutex<Vec<Vec<String>>>>,
410    /// Count of sessions started, observable by parked rendezvous sessions
411    /// (see [`MockScript::hold_result_until_started`]).
412    started_count: watch::Sender<usize>,
413}
414
415impl Default for MockBackend {
416    fn default() -> Self {
417        MockBackend {
418            scripts: Mutex::new(VecDeque::new()),
419            started_specs: Mutex::new(Vec::new()),
420            injected: Arc::new(Mutex::new(Vec::new())),
421            started_count: watch::channel(0).0,
422        }
423    }
424}
425
426impl MockBackend {
427    pub fn new() -> Self {
428        Self::default()
429    }
430
431    /// Backend pre-loaded with scripts (consumed FIFO by `start()`).
432    pub fn with_scripts(scripts: Vec<MockScript>) -> Self {
433        MockBackend {
434            scripts: Mutex::new(scripts.into()),
435            ..Default::default()
436        }
437    }
438
439    /// Queue another script at the back.
440    pub fn push_script(&self, script: MockScript) {
441        self.scripts
442            .lock()
443            .expect("mock scripts lock")
444            .push_back(script);
445    }
446
447    /// Clones of every spec passed to `start()`, in start order.
448    pub fn started_specs(&self) -> Vec<SessionSpec> {
449        self.started_specs.lock().expect("mock specs lock").clone()
450    }
451
452    /// User messages injected per session, aligned with start order.
453    pub fn injected_messages(&self) -> Vec<Vec<String>> {
454        self.injected.lock().expect("mock injected lock").clone()
455    }
456}
457
458#[async_trait::async_trait]
459impl AgentBackend for MockBackend {
460    async fn start(&self, spec: SessionSpec) -> Result<Box<dyn AgentSession>> {
461        let script = self
462            .scripts
463            .lock()
464            .expect("mock scripts lock")
465            .pop_front()
466            .ok_or_else(|| EngineError::Backend("mock: no script queued".to_string()))?;
467
468        let slot = {
469            let mut injected = self.injected.lock().expect("mock injected lock");
470            injected.push(Vec::new());
471            injected.len() - 1
472        };
473
474        for (rel_path, contents) in &script.writes {
475            let target = spec.cwd.join(rel_path);
476            if let Some(parent) = target.parent() {
477                std::fs::create_dir_all(parent).map_err(|e| {
478                    EngineError::Backend(format!(
479                        "mock: failed to create parent dirs for {}: {e}",
480                        target.display()
481                    ))
482                })?;
483            }
484            std::fs::write(&target, contents).map_err(|e| {
485                EngineError::Backend(format!("mock: failed to write {}: {e}", target.display()))
486            })?;
487        }
488
489        // Head-move seam (see [`MockScript::commits`]): record each scripted
490        // commit in the session cwd AFTER the scripted writes, so a validator
491        // script can turn a dirty tree into a moved HEAD inside its session.
492        if !script.commits.is_empty() {
493            let repo = crate::git_ops::GitRepo::open(&spec.cwd)?;
494            repo.ensure_identity()?;
495            for message in &script.commits {
496                repo.add_all_and_commit(message)?;
497            }
498        }
499
500        // Sabotage seam (see [`MockScript::removes`]): applied AFTER writes
501        // and commits, so a scripted worker can leave a deliverable AND an
502        // uninspectable tree — the order a real hostile worker's actions
503        // would produce.
504        for rel_path in &script.removes {
505            let target = spec.cwd.join(rel_path);
506            let metadata = std::fs::symlink_metadata(&target).map_err(|e| {
507                EngineError::Backend(format!(
508                    "mock: failed to stat {} for scripted removal: {e}",
509                    target.display()
510                ))
511            })?;
512            if metadata.is_dir() {
513                std::fs::remove_dir_all(&target).map_err(|e| {
514                    EngineError::Backend(format!(
515                        "mock: failed to remove {}: {e}",
516                        target.display()
517                    ))
518                })?;
519            } else {
520                std::fs::remove_file(&target).map_err(|e| {
521                    EngineError::Backend(format!(
522                        "mock: failed to remove {}: {e}",
523                        target.display()
524                    ))
525                })?;
526            }
527        }
528
529        // Egress-denial seam (see [`MockScript::proxy_connects`]): issue each
530        // scripted CONNECT through the proxy the runner wired into the spec
531        // BEFORE the session's events replay, so the proxy records the denial
532        // before the run's outcome is built. A scripted connect with no proxy
533        // env is a misconfigured test — fail loudly rather than vacuously
534        // produce zero denials.
535        for (host, port) in &script.proxy_connects {
536            let url = spec
537                .env
538                .get(crate::egress_proxy::HTTPS_PROXY_ENV)
539                .ok_or_else(|| {
540                    EngineError::Backend(format!(
541                        "mock: scripted CONNECT to {host}:{port} but the session spec carries no HTTPS_PROXY env"
542                    ))
543                })?;
544            let addr = url.strip_prefix("http://").ok_or_else(|| {
545                EngineError::Backend(format!("mock: HTTPS_PROXY {url:?} is not http://host:port"))
546            })?;
547            let mut stream = tokio::net::TcpStream::connect(addr).await.map_err(|e| {
548                EngineError::Backend(format!(
549                    "mock: failed to reach the egress proxy at {addr}: {e}"
550                ))
551            })?;
552            tokio::io::AsyncWriteExt::write_all(
553                &mut stream,
554                format!("CONNECT {host}:{port} HTTP/1.1\r\n\r\n").as_bytes(),
555            )
556            .await
557            .map_err(|e| {
558                EngineError::Backend(format!("mock: CONNECT {host}:{port} write failed: {e}"))
559            })?;
560            // Read the response head to its CRLF terminator: the proxy records
561            // a denial BEFORE answering 403, so awaiting the head guarantees
562            // the record exists once start() returns. (Read to the terminator,
563            // not EOF: an ALLOWED connect gets a 200 and the tunnel stays open.)
564            let mut head: Vec<u8> = Vec::new();
565            let mut byte = [0u8; 1];
566            while !head.ends_with(b"\r\n\r\n") && head.len() < 8192 {
567                let n = tokio::io::AsyncReadExt::read(&mut stream, &mut byte)
568                    .await
569                    .map_err(|e| {
570                        EngineError::Backend(format!(
571                            "mock: CONNECT {host}:{port} read failed: {e}"
572                        ))
573                    })?;
574                if n == 0 {
575                    break;
576                }
577                head.push(byte[0]);
578            }
579        }
580
581        let session_id = script
582            .session_id
583            .clone()
584            .unwrap_or_else(|| spec.session_id.clone());
585        self.started_specs
586            .lock()
587            .expect("mock specs lock")
588            .push(spec.clone());
589        self.started_count.send_modify(|count| *count += 1);
590
591        Ok(Box::new(MockSession {
592            spec,
593            session_id,
594            streaming: script.streaming,
595            pending: script.events.into(),
596            on_message: script.on_message,
597            script_exit: script.exit,
598            exit: None,
599            notify: Notify::new(),
600            injected: Arc::clone(&self.injected),
601            slot,
602            hold_result_until_started: script.hold_result_until_started,
603            started_count: self.started_count.subscribe(),
604        }))
605    }
606}
607
608// ---------------------------------------------------------------------------
609// Session
610// ---------------------------------------------------------------------------
611
612/// A scripted session handed out by [`MockBackend::start`].
613pub struct MockSession {
614    /// Clone of the spec this session was started with (also recorded on the
615    /// backend via [`MockBackend::started_specs`]).
616    pub spec: SessionSpec,
617    session_id: String,
618    streaming: bool,
619    pending: VecDeque<AgentEvent>,
620    on_message: VecDeque<Vec<AgentEvent>>,
621    script_exit: SessionExit,
622    /// Set exactly when the stream closes (None returned, or abort).
623    exit: Option<SessionExit>,
624    /// Wakes a parked streaming `next_event` after send/abort. `notify_one`
625    /// stores a permit, so a wake issued between a state check and the await
626    /// is never lost.
627    notify: Notify,
628    injected: Arc<Mutex<Vec<Vec<String>>>>,
629    slot: usize,
630    /// Rendezvous (see [`MockScript::hold_result_until_started`]): while
631    /// `Some(n)` and only the final event remains, `next_event` parks until
632    /// `started_count` reaches `n`, then clears itself.
633    hold_result_until_started: Option<usize>,
634    started_count: watch::Receiver<usize>,
635}
636
637#[async_trait::async_trait]
638impl AgentSession for MockSession {
639    fn session_id(&self) -> String {
640        self.session_id.clone()
641    }
642
643    async fn next_event(&mut self) -> Result<Option<AgentEvent>> {
644        // Yield once per call so cooperative schedulers interleave sessions
645        // realistically instead of draining one script synchronously.
646        tokio::task::yield_now().await;
647        loop {
648            if self.exit.is_some() {
649                // Closed (aborted or already finished). Abort drops any
650                // still-pending events, matching process-kill semantics.
651                return Ok(None);
652            }
653            // Rendezvous: the FINAL scripted event is withheld until enough
654            // sessions have started (deterministic wall-clock overlap).
655            if let Some(n) = self.hold_result_until_started {
656                if self.pending.len() == 1 {
657                    while *self.started_count.borrow() < n {
658                        if self.started_count.changed().await.is_err() {
659                            return Err(EngineError::Backend(format!(
660                                "mock: rendezvous({n}) abandoned — backend dropped with only \
661                                 {} session(s) started",
662                                *self.started_count.borrow()
663                            )));
664                        }
665                    }
666                    self.hold_result_until_started = None;
667                }
668            }
669            if let Some(event) = self.pending.pop_front() {
670                return Ok(Some(event));
671            }
672            if !self.streaming {
673                self.exit = Some(self.script_exit.clone());
674                return Ok(None);
675            }
676            // Streaming with an empty queue: park until send_user_message
677            // pushes the next batch or abort() closes the stream. The state
678            // is re-checked after every wakeup, so spurious wakes are safe.
679            self.notify.notified().await;
680        }
681    }
682
683    async fn send_user_message(&mut self, text: &str) -> Result<()> {
684        if !self.streaming {
685            return Err(EngineError::Backend(
686                "mock: send_user_message on non-streaming session".to_string(),
687            ));
688        }
689        if self.exit.is_some() {
690            return Err(EngineError::Backend(
691                "mock: send_user_message on closed session".to_string(),
692            ));
693        }
694        self.injected.lock().expect("mock injected lock")[self.slot].push(text.to_string());
695        if let Some(batch) = self.on_message.pop_front() {
696            self.pending.extend(batch);
697        }
698        self.notify.notify_one();
699        Ok(())
700    }
701
702    async fn abort(&mut self) -> Result<()> {
703        if self.exit.is_none() {
704            self.exit = Some(SessionExit::Aborted);
705        }
706        self.notify.notify_one();
707        Ok(())
708    }
709
710    fn exit_status(&self) -> Option<SessionExit> {
711        self.exit.clone()
712    }
713}