Skip to main content

leviath_cli/commands/agent_client/
mod.rs

1//! `lev agent-client` - serve a Leviath agent over the Agent **Client** Protocol.
2//!
3//! ## Which protocol this is
4//!
5//! This speaks the [Agent Client Protocol][acp]: JSON-RPC 2.0, newline-delimited,
6//! over **stdio**. It is the protocol Zed and Gas City use to drive a headless
7//! agent as a child process - `initialize` / `session/new` / `session/prompt` /
8//! `session/cancel`, with `session/update` notifications streaming output back.
9//!
10//! It is **not** the Agent *Communication* Protocol (a REST + SSE API from the
11//! BeeAI project). The two share the acronym "ACP" and nothing else. This command
12//! is the one that actually integrates Leviath with Gas City.
13//!
14//! ## How it works
15//!
16//! `lev agent-client` is a thin front end over the shared-world daemon, exactly
17//! like `lev run` / `lev serve` / `lev dash`: it owns no agent world of its own.
18//! A `session/prompt` spawns (or, on later prompts, messages) an agent in the
19//! daemon over the control socket, then translates the daemon's live
20//! [`WorldEvent`] stream and the run's per-stage output into `session/update`
21//! notifications until the run finishes or parks.
22//!
23//! [`WorldEvent`]: leviath_runtime::host::WorldEvent
24//!
25//! The protocol logic is [`serve_over`], which takes its reader/writer generically
26//! and erases them to trait objects internally, so the whole
27//! handshake→prompt→stream sequence is driven in tests over an in-memory duplex
28//! against a fake daemon - no process, no terminal, no real stdio.
29//!
30//! [acp]: https://agentclientprotocol.com
31
32mod session;
33mod translate;
34
35use std::path::PathBuf;
36
37use clap::Args;
38use leviath_agent_client::{
39    AgentCapabilities, AgentInfo, ContentBlock, InitializeParams, InitializeResult, JsonRpcMessage,
40    PROTOCOL_VERSION, PromptCapabilities, RequestPermissionResult, SessionCancelParams,
41    SessionNewParams, SessionNewResult, SessionPromptParams, SessionPromptResult, SessionUpdate,
42    SessionUpdateParams, StopReason, error_codes, flatten_prompt, is_permission_request,
43    parse_region_markers, permission_request,
44};
45use leviath_core::interaction::{ApprovalScope, InteractionRequest, InteractionResponse};
46use leviath_core::run_meta::RunStatus;
47use leviath_runtime::control_socket::{ControlClient, ControlRequest, ControlResponse};
48use leviath_runtime::host::WorldEvent;
49use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWrite, AsyncWriteExt};
50
51use self::mapping::{PermissionChoice, interpret_permission};
52use self::session::{ResolvedBlueprint, resolve_blueprint, spawn_args};
53use self::translate::{StageTail, split_chunks};
54
55/// How often, absent a daemon event, the loop flushes newly-written run output.
56const OUTPUT_POLL: std::time::Duration = std::time::Duration::from_millis(250);
57
58/// Arguments for `lev agent-client`.
59#[derive(Args, Debug, Clone, Default)]
60pub struct AgentClientArgs {
61    /// Blueprint to serve: an installed agent name, or a path to one. When
62    /// omitted, each session's working directory is searched for an
63    /// `agent.leviath`.
64    #[arg(long)]
65    pub agent: Option<String>,
66
67    /// Approve every tool call without prompting (recommended when the host does
68    /// not implement `session/request_permission`, e.g. Gas City).
69    #[arg(long)]
70    pub yolo: bool,
71
72    /// Allow a tool outright (repeatable).
73    #[arg(long)]
74    pub allow: Vec<String>,
75
76    /// Override the blueprint's max sub-agent tree depth.
77    #[arg(long)]
78    pub max_depth: Option<usize>,
79
80    /// Refuse the blueprint's `seed = { command = "..." }` regions. Those run a
81    /// shell command at spawn - before the first inference, and so before any
82    /// approval prompt.
83    #[arg(long)]
84    pub no_seed_commands: bool,
85
86    /// Ask the agent for its final output in this shape, overriding what the
87    /// blueprint declares. Any label works: it reaches the model, which produces
88    /// the bytes. The answer arrives as the turn's closing message.
89    #[arg(long, value_name = "LABEL")]
90    pub output_format: Option<String>,
91
92    /// Extra guidance about that shape, passed alongside `--output-format`.
93    #[arg(long, value_name = "TEXT")]
94    pub output_instructions: Option<String>,
95}
96
97/// The protocol server. Generic over transport at the boundary, then erased to
98/// trait objects internally (see the body).
99///
100/// Reads newline-delimited JSON-RPC messages from `reader`, drives the session
101/// state machine, and writes responses/notifications to `writer`. `control`
102/// reaches the shared-world daemon; `runs_dir` is the run-state root the output
103/// tailer reads from (injected so tests use a temp dir). Returns `Ok(())` when
104/// the client closes the input stream (EOF).
105pub async fn serve_over<R, W>(
106    reader: R,
107    writer: W,
108    control: ControlClient,
109    args: AgentClientArgs,
110    runs_dir: PathBuf,
111    default_cwd: String,
112) -> anyhow::Result<()>
113where
114    R: AsyncBufRead + Send + 'static,
115    W: AsyncWrite + Send + 'static,
116{
117    // Erase the reader/writer to trait objects so the (large, multi-branch)
118    // `Server` state machine has exactly ONE monomorphization regardless of the
119    // concrete transport. This is the same technique `leviath-cli`'s `serve`
120    // module uses on its shutdown future: without it, every distinct test
121    // reader/writer type spawns an unused `Server` instantiation whose
122    // never-called methods read as uncovered regions under the coverage gate.
123    let mut reader: BoxReader = Box::pin(reader);
124    let mut server = Server {
125        control,
126        args,
127        runs_dir,
128        writer: Box::pin(writer),
129        caps_present: false,
130        session: None,
131        next_request_id: 0,
132        io_alive: true,
133        default_cwd,
134    };
135    server.run(&mut reader).await;
136    Ok(())
137}
138
139/// The reader half, erased to a single trait-object type.
140type BoxReader = std::pin::Pin<Box<dyn AsyncBufRead + Send>>;
141/// The writer half, erased to a single trait-object type.
142type BoxWriter = std::pin::Pin<Box<dyn AsyncWrite + Send>>;
143
144/// The active session's mutable state. Gas City and Zed drive one session per
145/// process, so a single slot suffices; a fresh `session/new` replaces it.
146struct ActiveSession {
147    /// The protocol session id we minted.
148    session_id: String,
149    /// The blueprint this session runs.
150    blueprint: ResolvedBlueprint,
151    /// The session's working directory.
152    cwd: String,
153    /// The daemon run id, once the first prompt has spawned it.
154    run_id: Option<String>,
155}
156
157/// The protocol server: transport, daemon handle, and session state.
158struct Server {
159    control: ControlClient,
160    args: AgentClientArgs,
161    runs_dir: PathBuf,
162    writer: BoxWriter,
163    /// Whether the client advertised any capabilities at `initialize`. Hosts that
164    /// implement the client-side methods (and so can answer an agent-initiated
165    /// `session/request_permission`) send them; Gas City sends none, so tool
166    /// approvals are surfaced as output and parked instead of deadlocking on a
167    /// request the host will never answer.
168    caps_present: bool,
169    session: Option<ActiveSession>,
170    /// Monotonic id source for agent→client requests.
171    next_request_id: i64,
172    /// Working directory to use when a `session/new` omits (or empties) `cwd` -
173    /// the directory `lev agent-client` was launched from. Without this the
174    /// agent's workdir was an empty string, so it ran in the daemon's directory
175    /// rather than the caller's.
176    default_cwd: String,
177    /// Whether the output stream is still writable. Output is best-effort: a
178    /// failed write means the client is gone, so this flips to `false` and the
179    /// server winds down rather than propagating an error per write site (which
180    /// mirrors the WebSocket server's `send(...).is_err()` handling).
181    io_alive: bool,
182}
183
184/// The outcome of getting a run going for a prompt turn.
185enum RunStart {
186    /// The run is live under this id; stream it.
187    Ready(String),
188    /// A follow-up message could not be delivered (the agent already finished).
189    MessageUndeliverable,
190    /// The daemon refused to spawn the run, or was unreachable.
191    SpawnFailed,
192}
193
194/// What became of an interaction raised mid-turn.
195enum InteractionOutcome {
196    /// The interaction was resolved in-turn (via `session/request_permission`),
197    /// or the client cannot answer it and Leviath will handle it out of band -
198    /// either way the turn keeps streaming until the run reaches a done state.
199    Continue,
200    /// The interaction was surfaced as output and the turn ends now with this
201    /// reason, leaving the run parked for the client's next prompt. Used only for
202    /// clients that drive their own conversation (they advertised capabilities);
203    /// the client re-prompts with the answer.
204    Park(StopReason),
205}
206
207impl Server {
208    /// The top-level read/dispatch loop. Returns when the client closes stdin or
209    /// the output stream breaks.
210    async fn run(&mut self, reader: &mut BoxReader) {
211        while self.io_alive {
212            let Some(line) = read_line(reader).await else {
213                break; // EOF or a read error - the client is gone
214            };
215            let trimmed = line.trim();
216            if trimmed.is_empty() {
217                continue; // hosts may emit blank keep-alive lines
218            }
219            match serde_json::from_str::<JsonRpcMessage>(trimmed) {
220                Ok(msg) => self.dispatch(reader, msg).await,
221                Err(_) => {
222                    self.write(&JsonRpcMessage::error_response(
223                        serde_json::Value::Null,
224                        error_codes::PARSE_ERROR,
225                        "invalid JSON",
226                    ))
227                    .await;
228                }
229            }
230        }
231    }
232
233    /// Route one parsed message.
234    async fn dispatch(&mut self, reader: &mut BoxReader, msg: JsonRpcMessage) {
235        match (msg.method.as_deref(), msg.id.clone()) {
236            // ── Requests (have an id) ──
237            (Some("initialize"), Some(id)) => self.on_initialize(id, msg.params).await,
238            (Some("session/new"), Some(id)) => self.on_session_new(id, msg.params).await,
239            (Some("session/prompt"), Some(id)) => {
240                self.on_session_prompt(reader, id, msg.params).await
241            }
242            (Some(_other), Some(id)) => {
243                self.write(&JsonRpcMessage::error_response(
244                    id,
245                    error_codes::METHOD_NOT_FOUND,
246                    "method not supported",
247                ))
248                .await
249            }
250            // ── Notifications (no id) ──
251            (Some("session/cancel"), None) => self.on_cancel_notification(msg.params).await,
252            // Any other notification (`initialized`, unknown) is ignored, as is a
253            // stray response with neither method nor id.
254            _ => {}
255        }
256    }
257
258    /// `initialize`: advertise this agent's identity and capabilities, and note
259    /// whether the client advertised its own.
260    async fn on_initialize(&mut self, id: serde_json::Value, params: Option<serde_json::Value>) {
261        let params: InitializeParams = params
262            .and_then(|p| serde_json::from_value(p).ok())
263            .unwrap_or_default();
264        self.caps_present = params.client_capabilities.is_some();
265        let result = InitializeResult {
266            protocol_version: PROTOCOL_VERSION,
267            agent_capabilities: AgentCapabilities {
268                load_session: false,
269                prompt_capabilities: PromptCapabilities {
270                    image: false,
271                    audio: false,
272                    embedded_context: true,
273                },
274            },
275            agent_info: AgentInfo {
276                name: "leviath".to_string(),
277                version: env!("CARGO_PKG_VERSION").to_string(),
278            },
279            auth_methods: vec![],
280        };
281        self.write(&JsonRpcMessage::response(id, &result)).await;
282    }
283
284    /// `session/new`: resolve the blueprint and open a session. No run is spawned
285    /// yet - that waits for the first prompt.
286    async fn on_session_new(&mut self, id: serde_json::Value, params: Option<serde_json::Value>) {
287        let params: SessionNewParams = params
288            .and_then(|p| serde_json::from_value(p).ok())
289            .unwrap_or_default();
290        if !params.mcp_servers.is_empty() {
291            // Leviath blueprints declare their own MCP servers; client-supplied
292            // ones are captured for visibility but not injected (see module docs).
293            let ignored = params.mcp_servers.len();
294            tracing::info!(
295                mcp_server_count = ignored,
296                "session/new supplied MCP servers; ignoring in favour of the blueprint's own"
297            );
298        }
299        // An absent/empty `cwd` falls back to the directory `lev agent-client`
300        // was launched from, so the agent's tools operate there rather than in
301        // the daemon's working directory.
302        let cwd = if params.cwd.trim().is_empty() {
303            self.default_cwd.clone()
304        } else {
305            params.cwd
306        };
307        match resolve_blueprint(self.args.agent.as_deref(), &cwd) {
308            Ok(blueprint) => {
309                let session_id = new_session_id(&blueprint.agent_name);
310                self.session = Some(ActiveSession {
311                    session_id: session_id.clone(),
312                    blueprint,
313                    cwd,
314                    run_id: None,
315                });
316                self.write(&JsonRpcMessage::response(
317                    id,
318                    &SessionNewResult { session_id },
319                ))
320                .await;
321            }
322            Err(e) => {
323                self.write(&JsonRpcMessage::error_response(
324                    id,
325                    error_codes::INVALID_PARAMS,
326                    format!("no blueprint for this session: {e}"),
327                ))
328                .await;
329            }
330        }
331    }
332
333    /// `session/prompt`: run one prompt turn end to end and report its stop reason.
334    async fn on_session_prompt(
335        &mut self,
336        reader: &mut BoxReader,
337        id: serde_json::Value,
338        params: Option<serde_json::Value>,
339    ) {
340        if self.session.is_none() {
341            self.write(&JsonRpcMessage::error_response(
342                id,
343                error_codes::INVALID_REQUEST,
344                "no active session; call session/new first",
345            ))
346            .await;
347            return;
348        }
349        let params: SessionPromptParams = params
350            .and_then(|p| serde_json::from_value(p).ok())
351            .unwrap_or_default();
352        let text = flatten_prompt(&params.prompt);
353        if text.is_empty() {
354            self.write(&JsonRpcMessage::error_response(
355                id,
356                error_codes::INVALID_PARAMS,
357                "prompt has no usable text content",
358            ))
359            .await;
360            return;
361        }
362        // Parse `---region:<name>---` markers; with none, the whole text is the
363        // `task` region (back-compat).
364        let regions = parse_region_markers(&text);
365        let task = regions.get("task").cloned().unwrap_or_default();
366        let stop_reason = self.run_turn(reader, task, regions).await;
367        self.write(&JsonRpcMessage::response(
368            id,
369            &SessionPromptResult { stop_reason },
370        ))
371        .await;
372    }
373
374    /// `session/cancel` notification arriving between turns: cancel the session's
375    /// run if one exists.
376    async fn on_cancel_notification(&mut self, params: Option<serde_json::Value>) {
377        let _: SessionCancelParams = params
378            .and_then(|p| serde_json::from_value(p).ok())
379            .unwrap_or_default();
380        if let Some(run_id) = self.session.as_ref().and_then(|s| s.run_id.clone()) {
381            let _ = self
382                .control
383                .request(&ControlRequest::Cancel { run_id })
384                .await;
385        }
386    }
387
388    /// Drive one prompt turn: spawn (or message) the agent, then translate the
389    /// daemon's events and the run's output until it goes terminal or parks.
390    async fn run_turn(
391        &mut self,
392        reader: &mut BoxReader,
393        task: String,
394        regions: std::collections::HashMap<String, String>,
395    ) -> StopReason {
396        // Subscribe before spawning so no event between spawn and subscribe is
397        // missed. An unreachable daemon ends the turn as a refusal.
398        let Ok(mut stream) = self.control.subscribe().await else {
399            return StopReason::Refusal;
400        };
401
402        let session_id = self
403            .session
404            .as_ref()
405            .expect("session present")
406            .session_id
407            .clone();
408        let run_id = match self.start_run(task, regions).await {
409            RunStart::Ready(run_id) => run_id,
410            // The agent already finished and won't take another message - the
411            // turn is simply over, not a failure.
412            RunStart::MessageUndeliverable => return StopReason::EndTurn,
413            // The daemon refused to create the run, or was unreachable.
414            RunStart::SpawnFailed => return StopReason::Refusal,
415        };
416
417        let mut tail = StageTail::new();
418        while self.io_alive {
419            tokio::select! {
420                biased;
421                event = stream.next() => {
422                    let Some(event) = event else {
423                        // The daemon closed the stream (restart); end the turn
424                        // with whatever output we have.
425                        self.flush_output(&session_id, &mut tail, &run_id).await;
426                        return StopReason::EndTurn;
427                    };
428                    if event.run_id() != run_id {
429                        continue; // another run in the shared world
430                    }
431                    self.flush_output(&session_id, &mut tail, &run_id).await;
432                    match event {
433                        WorldEvent::Completed { status, final_output, .. } => {
434                            self.emit_final_output(&session_id, final_output.as_ref()).await;
435                            return leviath_agent_client::stop_reason_for_label(&status);
436                        }
437                        WorldEvent::Context { total_tokens, max_tokens, .. } => {
438                            self.emit_usage(&session_id, total_tokens, max_tokens).await;
439                        }
440                        WorldEvent::Interaction { request, .. } => {
441                            match self.handle_interaction(reader, &session_id, &run_id, request).await {
442                                InteractionOutcome::Continue => {}
443                                InteractionOutcome::Park(reason) => return reason,
444                            }
445                        }
446                        // Status / Tokens / Spawned: the output flush above is all
447                        // that's needed.
448                        _ => {}
449                    }
450                    // A run can reach a done state without a `Completed` event -
451                    // `CompleteInteractive` stays live for follow-up, so it emits
452                    // no terminal event. Consult the persisted run status after
453                    // every event so the turn ends when (and only when) the run is
454                    // genuinely finished, never while it is merely `WaitingInput`
455                    // on an interaction Leviath is handling out of band.
456                    if let Some(reason) = self.run_finished(&run_id) {
457                        return reason;
458                    }
459                }
460                _ = tokio::time::sleep(OUTPUT_POLL) => {
461                    self.flush_output(&session_id, &mut tail, &run_id).await;
462                    if let Some(reason) = self.run_finished(&run_id) {
463                        return reason;
464                    }
465                }
466                incoming = read_line(reader) => {
467                    match incoming {
468                        // Stdin closed mid-turn: the client is gone.
469                        None => {
470                            self.flush_output(&session_id, &mut tail, &run_id).await;
471                            return StopReason::EndTurn;
472                        }
473                        Some(line) => self.handle_midturn_input(&run_id, &line).await,
474                    }
475                }
476            }
477        }
478        // The output stream broke mid-turn; the client is gone.
479        StopReason::EndTurn
480    }
481
482    /// Whether the run has reached a state that should end the current turn,
483    /// read from its persisted `meta.json` status. Returns the stop reason to
484    /// report, or `None` while the run is still starting / running / blocked on
485    /// input (`WaitingInput`) - the latter must keep the turn in flight so a
486    /// non-interactive client is never told "done" while the agent is actually
487    /// waiting on an interaction Leviath is handling out of band.
488    fn run_finished(&self, run_id: &str) -> Option<StopReason> {
489        let status = read_run_status(&self.runs_dir, run_id)?;
490        leviath_agent_client::stop_reason_for(&status)
491    }
492
493    /// Spawn the agent on the first prompt, or deliver a message on later ones.
494    /// `regions` seeds named caller-input regions on the first (spawning) prompt;
495    /// on later prompts the text is delivered as a message and `regions` is unused.
496    async fn start_run(
497        &mut self,
498        task: String,
499        regions: std::collections::HashMap<String, String>,
500    ) -> RunStart {
501        let existing = self
502            .session
503            .as_ref()
504            .expect("session present")
505            .run_id
506            .clone();
507        match existing {
508            Some(run_id) => {
509                let delivered = matches!(
510                    self.control
511                        .request(&ControlRequest::Message {
512                            agent_id: run_id.clone(),
513                            content: task,
514                            target_region: None,
515                        })
516                        .await,
517                    Ok(ControlResponse::Ok { ok: true })
518                );
519                if delivered {
520                    RunStart::Ready(run_id)
521                } else {
522                    RunStart::MessageUndeliverable
523                }
524            }
525            None => {
526                let session = self.session.as_ref().expect("session present");
527                let spawn =
528                    spawn_args(&session.blueprint, &task, &session.cwd, &self.args, regions);
529                match self.control.spawn(spawn).await {
530                    Ok(ControlResponse::Spawned { run_id }) => {
531                        self.session.as_mut().expect("session present").run_id =
532                            Some(run_id.clone());
533                        RunStart::Ready(run_id)
534                    }
535                    _ => RunStart::SpawnFailed,
536                }
537            }
538        }
539    }
540
541    /// Handle an interaction raised while a turn is streaming.
542    ///
543    /// The strategy depends on whether the client advertised capabilities at
544    /// `initialize` (i.e. whether it implements the client-side protocol methods
545    /// and can answer an agent-initiated request):
546    ///
547    /// - **Capable client + tool approval** → drive it over
548    ///   `session/request_permission`, answered in-turn; the run continues.
549    /// - **Capable client + any other interaction** → surface the question as
550    ///   output and end the turn (`Park`). The client owns the conversation and
551    ///   re-prompts with the answer, which arrives as the next `session/prompt` -
552    ///   the standard Agent Client Protocol turn boundary.
553    /// - **Client without capabilities (e.g. Gas City, which reports interaction
554    ///   unsupported)** → surface the question as output and **keep the turn in
555    ///   flight** (`Continue`). The run is genuinely blocked, so the turn must not
556    ///   report "done"; the human resolves it through Leviath's own surfaces
557    ///   (`lev dash` / `lev respond`) and the run then continues to completion.
558    async fn handle_interaction(
559        &mut self,
560        reader: &mut BoxReader,
561        session_id: &str,
562        run_id: &str,
563        request: InteractionRequest,
564    ) -> InteractionOutcome {
565        if self.caps_present {
566            if is_permission_request(&request) {
567                return self
568                    .request_permission(reader, session_id, run_id, request)
569                    .await;
570            }
571            // A capable client drives its own conversation: surface the question
572            // and hand control back so it can re-prompt with the answer.
573            self.emit_chunk(session_id, &format!("{}\n", request.prompt))
574                .await;
575            return InteractionOutcome::Park(StopReason::EndTurn);
576        }
577        // A client that cannot answer interactions: surface the question and keep
578        // the turn alive. Leviath handles the interaction out of band; the turn
579        // ends only when the run itself reaches a done state.
580        self.emit_chunk(session_id, &format!("{}\n", request.prompt))
581            .await;
582        InteractionOutcome::Continue
583    }
584
585    /// Ask the host to approve a tool call and relay the decision to the daemon.
586    async fn request_permission(
587        &mut self,
588        reader: &mut BoxReader,
589        session_id: &str,
590        run_id: &str,
591        request: InteractionRequest,
592    ) -> InteractionOutcome {
593        let request_id = serde_json::json!(self.next_id());
594        let params = permission_request(session_id, &request);
595        self.write(&JsonRpcMessage::request(
596            request_id.clone(),
597            "session/request_permission",
598            &params,
599        ))
600        .await;
601
602        // Await the matching response. Other inbound messages during the wait are
603        // ignored except a cancel, which rejects the call and cancels the run.
604        loop {
605            let Some(line) = read_line(reader).await else {
606                return InteractionOutcome::Park(StopReason::EndTurn);
607            };
608            let Ok(msg) = serde_json::from_str::<JsonRpcMessage>(line.trim()) else {
609                continue;
610            };
611            if msg.method.as_deref() == Some("session/cancel") {
612                let _ = self
613                    .control
614                    .request(&ControlRequest::Cancel {
615                        run_id: run_id.to_string(),
616                    })
617                    .await;
618                self.answer_interaction(&request.id, false, ApprovalScope::Once)
619                    .await;
620                return InteractionOutcome::Continue;
621            }
622            if msg.id.as_ref() == Some(&request_id) {
623                let choice = msg
624                    .result
625                    .and_then(|r| serde_json::from_value::<RequestPermissionResult>(r).ok())
626                    .map(|r| interpret_permission(&r.outcome))
627                    .unwrap_or(PermissionChoice {
628                        approved: false,
629                        scope: ApprovalScope::Once,
630                    });
631                self.answer_interaction(&request.id, choice.approved, choice.scope)
632                    .await;
633                return InteractionOutcome::Continue;
634            }
635            // Unrelated message; keep waiting.
636        }
637    }
638
639    /// Relay an approval decision to the daemon's interaction hub.
640    ///
641    /// Takes `&mut self` (though it only reads `self.control`) so the future
642    /// stays `Send`: a shared `&Server` would require `Server: Sync`, which the
643    /// erased `dyn AsyncWrite` writer is not.
644    async fn answer_interaction(&mut self, request_id: &str, approved: bool, scope: ApprovalScope) {
645        let response = InteractionResponse {
646            request_id: request_id.to_string(),
647            value: None,
648            choice_index: None,
649            approved: Some(approved),
650            scope: Some(scope),
651        };
652        let _ = self
653            .control
654            .request(&ControlRequest::AnswerInteraction { response })
655            .await;
656    }
657
658    /// A message received while a turn is in flight. Only `session/cancel` (for
659    /// this run) is actionable; everything else is ignored.
660    async fn handle_midturn_input(&mut self, run_id: &str, line: &str) {
661        let Ok(msg) = serde_json::from_str::<JsonRpcMessage>(line.trim()) else {
662            return;
663        };
664        if msg.method.as_deref() == Some("session/cancel") {
665            let _ = self
666                .control
667                .request(&ControlRequest::Cancel {
668                    run_id: run_id.to_string(),
669                })
670                .await;
671        }
672    }
673
674    /// Flush any newly-written run output as `agent_message_chunk` notifications,
675    /// split into host-safe frames.
676    async fn flush_output(&mut self, session_id: &str, tail: &mut StageTail, run_id: &str) {
677        let text = tail.pump(&self.runs_dir, run_id);
678        for chunk in split_chunks(&text) {
679            self.emit_chunk(session_id, chunk).await;
680        }
681    }
682
683    /// Emit one `agent_message_chunk` update.
684    async fn emit_chunk(&mut self, session_id: &str, text: &str) {
685        let params = SessionUpdateParams {
686            session_id: session_id.to_string(),
687            update: SessionUpdate::AgentMessageChunk {
688                content: ContentBlock::text(text),
689            },
690        };
691        self.write(&JsonRpcMessage::notification("session/update", &params))
692            .await;
693    }
694
695    /// Emit the run's submitted answer as the agent's closing message.
696    ///
697    /// ACP has no result field: a turn returns a stop reason, and everything the
698    /// host shows the user arrived as `agent_message_chunk` updates. Those carry
699    /// the stage's streamed output, which is what the agent *did*. A submitted
700    /// answer never appears there - `submit_output` writes a component and a
701    /// context region, not the stage log - so without this an ACP host would
702    /// watch the whole run and never receive its conclusion.
703    ///
704    /// Prefixed and separated so the answer reads as a distinct closing message
705    /// rather than more streamed output. A run that submitted nothing emits
706    /// nothing, leaving the turn exactly as it was.
707    async fn emit_final_output(
708        &mut self,
709        session_id: &str,
710        output: Option<&leviath_core::output::FinalOutput>,
711    ) {
712        let Some(output) = output else { return };
713        let shape = output
714            .format
715            .as_deref()
716            .map(|f| format!(" ({f})"))
717            .unwrap_or_default();
718        let text = format!("\n\n--- final output{shape} ---\n{}", output.content);
719        for chunk in split_chunks(&text) {
720            self.emit_chunk(session_id, chunk).await;
721        }
722    }
723
724    /// Emit one `usage_update` update.
725    async fn emit_usage(&mut self, session_id: &str, used: usize, size: usize) {
726        let params = SessionUpdateParams {
727            session_id: session_id.to_string(),
728            update: SessionUpdate::UsageUpdate { used, size },
729        };
730        self.write(&JsonRpcMessage::notification("session/update", &params))
731            .await;
732    }
733
734    /// Serialize one message as a single line and flush it. Output is
735    /// best-effort: on any write error the client is assumed gone and
736    /// [`Server::io_alive`] flips to `false`, winding the server down.
737    async fn write(&mut self, msg: &JsonRpcMessage) {
738        let mut line = serde_json::to_string(msg).expect("JsonRpcMessage always serializes");
739        line.push('\n');
740        let ok = self.writer.write_all(line.as_bytes()).await.is_ok()
741            && self.writer.flush().await.is_ok();
742        if !ok {
743            self.io_alive = false;
744        }
745    }
746
747    /// Next agent→client request id.
748    fn next_id(&mut self) -> i64 {
749        self.next_request_id += 1;
750        self.next_request_id
751    }
752}
753
754/// Read the persisted `RunStatus` for `run_id` from `<runs_dir>/<run_id>/meta.json`.
755///
756/// Deserializes into a minimal projection that reads only the `status` field, so
757/// it does not depend on the full [`RunMeta`](leviath_core::run_meta::RunMeta)
758/// shape and tolerates a partially-written or older metadata file. Returns
759/// `None` if the file is missing or unreadable (the run hasn't persisted yet).
760fn read_run_status(runs_dir: &std::path::Path, run_id: &str) -> Option<RunStatus> {
761    #[derive(serde::Deserialize)]
762    struct StatusOnly {
763        status: RunStatus,
764    }
765    let path = runs_dir.join(run_id).join("meta.json");
766    let json = std::fs::read_to_string(path).ok()?;
767    serde_json::from_str::<StatusOnly>(&json)
768        .ok()
769        .map(|s| s.status)
770}
771
772/// The stop reason to report for a run in `status`, or `None` if the run has not
773/// finished and the turn should keep streaming.
774///
775/// `CompleteInteractive` counts as finished: the agent completed its required
776/// work and is only idling for optional follow-up, so control returns to the
777/// client. `WaitingInput` does **not** - the agent is blocked on an interaction,
778/// which is exactly the state that must not be reported as "done".
779/// Mint a session id from the agent name - reuses the run-id generator's
780/// collision-resistant `<name>-<timestamp>-<suffix>` scheme.
781fn new_session_id(agent_name: &str) -> String {
782    crate::runstate::new_run_id(agent_name)
783}
784
785/// Read one newline-terminated line, or `None` at end of stream.
786///
787/// A read error is treated the same as EOF (`None`): either way the client is no
788/// longer sending, and there is nothing useful to do but wind down. Collapsing
789/// the error into `None` via `unwrap_or(0)` also keeps this branch-free for the
790/// coverage gate.
791async fn read_line(reader: &mut BoxReader) -> Option<String> {
792    let mut line = String::new();
793    match reader.read_line(&mut line).await.unwrap_or(0) {
794        0 => None,
795        _ => Some(line),
796    }
797}
798
799/// Small, pure translations for the prompt loop, kept apart so they unit-test in
800/// isolation from the async machinery.
801mod mapping {
802    use leviath_agent_client::PermissionOutcome;
803    use leviath_agent_client::mapping::{
804        OPTION_ALLOW_ALWAYS, OPTION_ALLOW_ONCE, OPTION_REJECT_ONCE,
805    };
806    use leviath_core::interaction::ApprovalScope;
807
808    /// A resolved permission decision to relay to the daemon.
809    pub(super) struct PermissionChoice {
810        /// Whether the tool call is approved.
811        pub(super) approved: bool,
812        /// The scope of the decision.
813        pub(super) scope: ApprovalScope,
814    }
815
816    /// Interpret a host's permission outcome into an approve/deny + scope.
817    ///
818    /// The three option ids we offer map to allow-once, allow-for-the-run, and
819    /// reject. A `cancelled` outcome, or any option id we did not offer, is
820    /// treated as a one-time rejection - the safe default.
821    ///
822    /// There is no stage-scoped option: the protocol has three permission
823    /// kinds, and an editor host that knows nothing about this run's stages has
824    /// nothing to say about them.
825    pub(super) fn interpret_permission(outcome: &PermissionOutcome) -> PermissionChoice {
826        match outcome {
827            PermissionOutcome::Selected { option_id } if option_id == OPTION_ALLOW_ONCE => {
828                PermissionChoice {
829                    approved: true,
830                    scope: ApprovalScope::Once,
831                }
832            }
833            PermissionOutcome::Selected { option_id } if option_id == OPTION_ALLOW_ALWAYS => {
834                PermissionChoice {
835                    approved: true,
836                    scope: ApprovalScope::Run,
837                }
838            }
839            PermissionOutcome::Selected { option_id } if option_id == OPTION_REJECT_ONCE => {
840                PermissionChoice {
841                    approved: false,
842                    scope: ApprovalScope::Once,
843                }
844            }
845            _ => PermissionChoice {
846                approved: false,
847                scope: ApprovalScope::Once,
848            },
849        }
850    }
851
852    /// The stop reason to report for a run whose `WorldEvent::Completed` carried
853    /// `status`. The host emits only the terminal statuses `complete`, `error`,
854    /// and `cancelled`.
855    #[cfg(test)]
856    mod tests {
857        use super::*;
858
859        #[test]
860        fn interpret_maps_every_offered_option() {
861            let allow_once = interpret_permission(&PermissionOutcome::Selected {
862                option_id: OPTION_ALLOW_ONCE.to_string(),
863            });
864            assert!(allow_once.approved);
865            assert_eq!(allow_once.scope, ApprovalScope::Once);
866
867            let allow_always = interpret_permission(&PermissionOutcome::Selected {
868                option_id: OPTION_ALLOW_ALWAYS.to_string(),
869            });
870            assert!(allow_always.approved);
871            assert_eq!(allow_always.scope, ApprovalScope::Run);
872
873            let reject = interpret_permission(&PermissionOutcome::Selected {
874                option_id: OPTION_REJECT_ONCE.to_string(),
875            });
876            assert!(!reject.approved);
877            assert_eq!(reject.scope, ApprovalScope::Once);
878        }
879
880        #[test]
881        fn interpret_denies_unknown_option_and_cancellation() {
882            let unknown = interpret_permission(&PermissionOutcome::Selected {
883                option_id: "made-up".to_string(),
884            });
885            assert!(!unknown.approved);
886            assert_eq!(unknown.scope, ApprovalScope::Once);
887
888            let cancelled = interpret_permission(&PermissionOutcome::Cancelled);
889            assert!(!cancelled.approved);
890            assert_eq!(cancelled.scope, ApprovalScope::Once);
891        }
892    }
893}
894
895#[cfg(test)]
896mod tests;