Skip to main content

mj_controller/server/
api.rs

1//! The documented HTTP API an orchestrating agent drives sessions with.
2//!
3//! The web viewer's own `/api/...` routes exist for the browser: they are
4//! undocumented, cookie-only, and shaped around what a phone renders. These
5//! `/api/v1/...` routes are the stable surface instead. They authenticate with
6//! a bearer token from a file the same user can read, answer with a version
7//! header so a client can tell which contract it reached, and — the point of
8//! the whole module — let a caller block until one specific prompt finishes and
9//! read a structured outcome for it.
10//!
11//! Everything that needs the daemon's live session actors or its SQLite store
12//! reaches them through [`SubagentBackend`], because this crate cannot depend
13//! on the daemon runtime that owns them.
14
15mod events;
16
17use std::path::{Component, PathBuf};
18use std::sync::Arc;
19use std::time::Duration;
20
21use anyhow::{Context, Result as AnyResult};
22use axum::extract::{Path, Query, State};
23use axum::http::header::{
24    AUTHORIZATION, CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_TYPE, COOKIE, HeaderValue,
25};
26use axum::http::{Request as HttpRequest, StatusCode};
27use axum::middleware::Next;
28use axum::response::{IntoResponse, Response};
29use axum::routing::{get, post};
30use axum::{Json, Router};
31use serde::{Deserialize, Serialize};
32
33use mj_core::state::{
34    MaterializedExecutionState, MaterializedTurn, MaterializedTurnOutcome, TurnOutcomeKind,
35};
36
37use mj_core::relay::{CapacityRetry, is_capacity_stop_reason};
38
39use mj_client::session::{BoxFuture, SessionHandle};
40
41use super::{
42    ActionOutcome, ApiError, COOKIE_NAME, ControllerAction, ControllerRequest, ServerState,
43    ViewerLifecycleCategory, ViewerSession, ViewerSnapshot, constant_time_eq, cookie_value,
44    create_quick_bundle, now_unix, require_session_record, session_cookie_valid, validate_action,
45    validate_prompt_text,
46};
47
48/// Response header naming the contract version this server speaks. A client
49/// that understands only version 1 can refuse anything else without parsing a
50/// body it may not recognize.
51pub const API_VERSION_HEADER: &str = "mj-api-version";
52pub const API_VERSION: &str = "1";
53
54/// How long a wait blocks when the caller names no timeout, and the ceiling it
55/// may ask for. Both are generous: a turn routinely runs for minutes, and the
56/// caller is a program that reconnects rather than a person holding a page.
57pub const DEFAULT_WAIT_SECS: u64 = 600;
58pub use mj_core::subagent::MAX_WAIT_SECONDS as MAX_WAIT_SECS;
59
60/// How often a wait re-reads durable state for a session with no live actor.
61const STOPPED_POLL_INTERVAL: Duration = Duration::from_millis(500);
62
63const API_TOKEN_FILE: &str = "api-token";
64const API_TOKEN_BYTES: usize = 32;
65
66/// Where the bearer token lives. It is a file rather than an environment
67/// variable so it survives daemon restarts and so deleting it is the explicit
68/// revoke gesture.
69pub fn api_token_path() -> PathBuf {
70    mj_core::config::data_dir().join(API_TOKEN_FILE)
71}
72
73/// Read the API bearer token, minting one on first use.
74///
75/// A missing file is ordinary first use. An unreadable or too-short one is
76/// replaced loudly: refusing to start the daemon over a damaged token file
77/// would be a worse answer than asking the caller to re-read the file.
78pub fn load_or_create_api_token(path: &std::path::Path) -> AnyResult<String> {
79    match std::fs::read_to_string(path) {
80        Ok(token) if token.trim().len() >= 32 => return Ok(token.trim().to_owned()),
81        Ok(token) => tracing::warn!(
82            path = %path.display(),
83            bytes = token.trim().len(),
84            "Mjolnir API token is too short; generating a new one revokes the old token"
85        ),
86        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
87        Err(error) => tracing::warn!(
88            path = %path.display(),
89            "could not read the Mjolnir API token ({error}); generating a new one revokes the old token"
90        ),
91    }
92    let mut bytes = [0_u8; API_TOKEN_BYTES];
93    getrandom::fill(&mut bytes)
94        .map_err(|error| anyhow::anyhow!("generate Mjolnir API token: {error}"))?;
95    let token = hex_lower(&bytes);
96    mj_core::config::atomic_write(path, token.as_bytes())
97        .with_context(|| format!("persist Mjolnir API token {}", path.display()))?;
98    Ok(token)
99}
100
101fn hex_lower(bytes: &[u8]) -> String {
102    use std::fmt::Write as _;
103    bytes.iter().fold(String::new(), |mut text, byte| {
104        let _ = write!(text, "{byte:02x}");
105        text
106    })
107}
108
109// ---------------------------------------------------------------------------
110// Failures
111// ---------------------------------------------------------------------------
112
113/// An API failure with a message written for the caller.
114///
115/// The phone surface deliberately answers with fixed strings, because its
116/// errors would otherwise name profile homes and SSH hosts to a browser. Here
117/// the caller is the same user who owns the daemon, and the whole value of the
118/// API is knowing *why* a turn or an export failed, so the message is dynamic.
119#[derive(Debug)]
120pub struct ApiFailure {
121    pub status: StatusCode,
122    pub message: String,
123}
124
125impl ApiFailure {
126    pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
127        Self {
128            status,
129            message: message.into(),
130        }
131    }
132
133    pub fn bad_request(message: impl Into<String>) -> Self {
134        Self::new(StatusCode::BAD_REQUEST, message)
135    }
136
137    pub fn conflict(message: impl Into<String>) -> Self {
138        Self::new(StatusCode::CONFLICT, message)
139    }
140
141    pub fn not_found(message: impl Into<String>) -> Self {
142        Self::new(StatusCode::NOT_FOUND, message)
143    }
144
145    pub fn unavailable(message: impl Into<String>) -> Self {
146        Self::new(StatusCode::SERVICE_UNAVAILABLE, message)
147    }
148}
149
150impl std::fmt::Display for ApiFailure {
151    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        write!(formatter, "{}: {}", self.status, self.message)
153    }
154}
155
156impl From<ApiError> for ApiFailure {
157    fn from(error: ApiError) -> Self {
158        Self::new(error.status, error.message)
159    }
160}
161
162impl From<anyhow::Error> for ApiFailure {
163    fn from(error: anyhow::Error) -> Self {
164        Self::new(StatusCode::INTERNAL_SERVER_ERROR, format!("{error:#}"))
165    }
166}
167
168#[derive(Debug, Serialize)]
169struct FailureBody {
170    error: String,
171}
172
173impl IntoResponse for ApiFailure {
174    fn into_response(self) -> Response {
175        (
176            self.status,
177            Json(FailureBody {
178                error: self.message,
179            }),
180        )
181            .into_response()
182    }
183}
184
185// ---------------------------------------------------------------------------
186// Wire types
187// ---------------------------------------------------------------------------
188
189/// Observed provider-owned background work; absent when no live snapshot is available.
190#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
191pub struct ApiBackgroundWork {
192    pub known: Option<bool>,
193    pub tasks: Vec<mj_core::relay::BackgroundCommand>,
194}
195
196impl From<&mj_core::relay::RelayOperationalState> for ApiBackgroundWork {
197    fn from(state: &mj_core::relay::RelayOperationalState) -> Self {
198        Self {
199            known: state.background_work_known,
200            tasks: state.background_commands.clone(),
201        }
202    }
203}
204
205/// One session as the API presents it. This is a narrower, more stable shape
206/// than the viewer's own session projection, which changes whenever the browser
207/// needs something new.
208#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
209pub struct ApiSession {
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub background_work: Option<ApiBackgroundWork>,
212    pub id: String,
213    pub workspace_id: String,
214    pub title: String,
215    pub harness_kind: String,
216    pub profile_id: String,
217    pub target_id: String,
218    pub bundle_id: String,
219    pub state: String,
220    pub lifecycle: ViewerLifecycleCategory,
221    pub chat_phase: super::ViewerChatPhase,
222    pub is_idle: bool,
223    pub has_error: bool,
224    pub created_at: String,
225    pub updated_at: String,
226    /// How the last finished prompt ended. Absent unless the caller asked for
227    /// one session by id or waited on it, because the dashboard projection the
228    /// list is built from does not carry turn identity.
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub last_turn_outcome: Option<MaterializedTurnOutcome>,
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub last_turn_diagnostic: Option<mj_core::diagnostic::TurnDiagnostic>,
233    #[serde(default)]
234    pub config_options: Vec<super::ViewerConfigOption>,
235    #[serde(default, skip_serializing_if = "Vec::is_empty")]
236    pub pending_elicitations: Vec<mj_core::elicitation::ElicitationRequest>,
237}
238
239impl From<&ViewerSession> for ApiSession {
240    fn from(session: &ViewerSession) -> Self {
241        Self {
242            background_work: None,
243            id: session.id.clone(),
244            workspace_id: session.workspace_id.clone(),
245            title: session.title.clone(),
246            harness_kind: session.harness_kind.clone(),
247            profile_id: session.profile_id.clone(),
248            target_id: session.target_id.clone(),
249            bundle_id: session.bundle_id.clone(),
250            state: session.state.clone(),
251            lifecycle: session.lifecycle,
252            chat_phase: session.chat_phase,
253            is_idle: session.is_idle,
254            has_error: session.has_error,
255            created_at: session.created_at.clone(),
256            updated_at: session.updated_at.clone(),
257            last_turn_outcome: None,
258            last_turn_diagnostic: None,
259            config_options: session.config_options.clone(),
260            pending_elicitations: session.pending_elicitations.clone(),
261        }
262    }
263}
264
265#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
266pub struct SessionListResponse {
267    pub sessions: Vec<ApiSession>,
268}
269
270/// Create a session and, optionally, send its first prompt. Served in M2.
271#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
272#[serde(deny_unknown_fields)]
273pub struct StartSessionRequest {
274    #[serde(default)]
275    pub create_managed_worktree: Option<bool>,
276    /// None follows the global `[subagents] enabled` setting.
277    #[serde(default)]
278    pub mjolnir_subagents: Option<bool>,
279    #[serde(default)]
280    pub workspace_id: Option<String>,
281    pub profile_id: String,
282    pub target_id: String,
283    #[serde(default)]
284    pub bundle_id: Option<String>,
285    #[serde(default)]
286    pub project_directory: Option<PathBuf>,
287    #[serde(default)]
288    pub title: Option<String>,
289    #[serde(default)]
290    pub model: Option<String>,
291    #[serde(default)]
292    pub effort: Option<String>,
293    #[serde(default)]
294    pub prompt: Option<String>,
295}
296
297#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
298pub struct StartSessionResponse {
299    pub session_id: String,
300    /// The turn the follow-up prompt was accepted as, once it has been
301    /// submitted. Creation answers before that, so it is usually absent.
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub turn_id: Option<u64>,
304}
305
306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
307#[serde(deny_unknown_fields)]
308pub struct SubagentSourceRange {
309    pub file: PathBuf,
310    pub start: u64,
311    pub end: u64,
312}
313
314#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
315#[serde(deny_unknown_fields)]
316pub struct SpawnSubagentRequest {
317    pub task_name: String,
318    pub instructions: String,
319    #[serde(default)]
320    pub profile_id: Option<String>,
321    #[serde(default)]
322    pub model: Option<String>,
323    #[serde(default)]
324    pub effort: Option<String>,
325    #[serde(default)]
326    pub working_directory: Option<PathBuf>,
327    #[serde(default)]
328    pub context: Option<String>,
329    #[serde(default)]
330    pub files: Vec<SubagentSourceRange>,
331    pub request_key: String,
332}
333
334#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
335pub struct SubagentView {
336    pub parent_session_id: String,
337    pub task_name: String,
338    pub request_key: String,
339    pub session: ApiSession,
340}
341
342#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
343pub struct SubagentListResponse {
344    pub subagents: Vec<SubagentView>,
345}
346
347#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
348#[serde(deny_unknown_fields)]
349pub struct PromptRequest {
350    pub text: String,
351}
352
353#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
354pub struct PromptResponse {
355    /// The relay acceptance ordinal for this prompt, which is what `wait`
356    /// takes as `turn_id`.
357    pub turn_id: u64,
358}
359
360#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
361#[serde(deny_unknown_fields)]
362pub struct WaitRequest {
363    /// Return when the harness presents a structured input request.
364    #[serde(default)]
365    pub return_on_input: bool,
366    /// Wait for this specific prompt. Absent means "wait until the session is
367    /// idle with nothing queued", which is what a caller that lost its turn id
368    /// wants.
369    #[serde(default)]
370    pub turn_id: Option<u64>,
371    #[serde(default)]
372    pub timeout_secs: Option<u64>,
373}
374
375/// How a wait ended.
376#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
377#[serde(rename_all = "snake_case")]
378pub enum WaitOutcome {
379    /// A structured elicitation needs an answer; only returned by opt-in waits.
380    InputRequired,
381    /// The turn completed normally.
382    Finished,
383    /// The turn failed, was rejected, or the session reported an error.
384    Error,
385    /// The turn was cancelled or interrupted.
386    Cancelled,
387    /// The model was at capacity and no retry is armed.
388    QuotaLimit,
389    /// The wait's deadline passed with the turn still running.
390    Timeout,
391    /// The session stopped or is stopping, so no turn can finish on it.
392    Stopped,
393}
394
395#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
396pub struct WaitCapacityRetry {
397    pub attempt: u32,
398    pub retry_at_ms: i64,
399}
400
401impl From<&CapacityRetry> for WaitCapacityRetry {
402    fn from(retry: &CapacityRetry) -> Self {
403        Self {
404            attempt: retry.attempt,
405            retry_at_ms: retry.retry_at_ms,
406        }
407    }
408}
409
410/// How the daemon's live view of a session's relay is doing.
411///
412/// This reports; it never decides an outcome. A caller that gets `timeout`
413/// needs to tell "the turn is still working" from "the daemon cannot see the
414/// worker at all", and those look identical without it.
415#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
416#[serde(rename_all = "snake_case")]
417pub enum RelayState {
418    /// The daemon is attached to the worker and following its events.
419    Connected,
420    /// Not attached, with no error recorded yet: attaching, or between tries.
421    Disconnected,
422    /// The worker could not be reached.
423    Unreachable,
424    /// The session's target is gone.
425    TargetMissing,
426    /// The event stream did not line up with what the daemon had projected.
427    ProjectionIntegrity,
428}
429
430#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
431pub struct RelayHealth {
432    pub state: RelayState,
433    /// The view's own description of the problem, when it recorded one.
434    #[serde(default, skip_serializing_if = "Option::is_none")]
435    pub detail: Option<String>,
436}
437
438impl From<&mj_client::session::ManagedSessionView> for RelayHealth {
439    fn from(view: &mj_client::session::ManagedSessionView) -> Self {
440        use mj_client::session::ViewError;
441        // A recorded error outranks `connected`: it is the specific thing
442        // standing between the caller and a finished turn.
443        match &view.error {
444            Some(error) => Self {
445                state: match error {
446                    ViewError::Unreachable(_) => RelayState::Unreachable,
447                    ViewError::TargetMissing(_) => RelayState::TargetMissing,
448                    ViewError::ProjectionIntegrity(_) => RelayState::ProjectionIntegrity,
449                },
450                detail: Some(error.detail().to_owned()),
451            },
452            None if view.connected => Self {
453                state: RelayState::Connected,
454                detail: None,
455            },
456            None => Self {
457                state: RelayState::Disconnected,
458                detail: None,
459            },
460        }
461    }
462}
463
464#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
465pub struct WaitResponse {
466    #[serde(default, skip_serializing_if = "Option::is_none")]
467    pub diagnostic: Option<mj_core::diagnostic::TurnDiagnostic>,
468
469    #[serde(default, skip_serializing_if = "Vec::is_empty")]
470    pub pending_elicitations: Vec<mj_core::elicitation::ElicitationRequest>,
471    #[serde(default, skip_serializing_if = "Option::is_none")]
472    pub usage: Option<mj_core::usage::TokenUsage>,
473    pub outcome: WaitOutcome,
474    /// The harness's own stop reason, when the turn reached one.
475    #[serde(default, skip_serializing_if = "Option::is_none")]
476    pub stop_reason: Option<String>,
477    /// Why the wait ended this way, when there is something to say.
478    #[serde(default, skip_serializing_if = "Option::is_none")]
479    pub message: Option<String>,
480    /// The agent's last message of the turn, flattened to text.
481    #[serde(default, skip_serializing_if = "Option::is_none")]
482    pub final_message: Option<String>,
483    #[serde(default, skip_serializing_if = "Option::is_none")]
484    pub turn_id: Option<u64>,
485    /// One-based position of this turn in the conversation.
486    #[serde(default, skip_serializing_if = "Option::is_none")]
487    pub turn_number: Option<u64>,
488    #[serde(default, skip_serializing_if = "Option::is_none")]
489    pub elapsed_ms: Option<i64>,
490    /// A capacity retry the worker has armed. While one is pending the caller
491    /// must not submit its own prompt: it would collide with the retry.
492    #[serde(default, skip_serializing_if = "Option::is_none")]
493    pub capacity_retry: Option<WaitCapacityRetry>,
494    /// The health of the daemon's live view of this session. Absent when no
495    /// live actor holds the session, because there is then no view to report
496    /// on and inventing one would be worse than saying nothing.
497    #[serde(default, skip_serializing_if = "Option::is_none")]
498    pub relay: Option<RelayHealth>,
499    pub session: ApiSession,
500}
501
502/// How many transcript items a page carries when the caller names no limit,
503/// and the most it may ask for. A caller that asks for more gets the ceiling
504/// rather than an error: paging is the point, and refusing a large limit would
505/// only make the caller retry with a smaller one.
506pub const DEFAULT_TRANSCRIPT_LIMIT: usize = 200;
507pub const MAX_TRANSCRIPT_LIMIT: usize = 1_000;
508
509#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
510pub struct TranscriptQuery {
511    #[serde(default)]
512    pub role: Option<mj_core::transcript::TranscriptRole>,
513    /// Resume from the highest sequence the caller has already seen.
514    #[serde(default)]
515    pub after_seq: Option<u64>,
516    #[serde(default)]
517    pub limit: Option<usize>,
518}
519
520#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
521pub struct TranscriptItemView {
522    pub stable_id: String,
523    pub position: u64,
524    /// What to pass as the next `after_seq`. It is the position for everything
525    /// but an agent message, which carries the ordinal of its latest content.
526    pub seq: u64,
527    pub role: String,
528    /// The item flattened to text, which is what a reading caller wants.
529    pub text: String,
530    pub created_at_ms: i64,
531    pub last_changed_at_ms: i64,
532    /// The stored body, for a caller that needs the structure behind the text.
533    pub body: mj_core::transcript::TranscriptBody,
534}
535
536#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
537pub struct TranscriptResponse {
538    #[serde(default)]
539    pub next_after_seq: u64,
540    pub session_id: String,
541    /// The newest sequence in the whole transcript. A page whose last item
542    /// reaches this is up to date.
543    pub latest_seq: u64,
544    pub execution: MaterializedExecutionState,
545    pub items: Vec<TranscriptItemView>,
546}
547
548// ---------------------------------------------------------------------------
549// Backend
550// ---------------------------------------------------------------------------
551
552/// Where a session stands turn by turn, read from the durable projection when
553/// no live actor holds the session.
554#[derive(Debug, Clone, PartialEq, Eq)]
555pub struct TurnState {
556    pub execution: MaterializedExecutionState,
557    pub active_turn: Option<MaterializedTurn>,
558    pub last_turn_outcome: Option<MaterializedTurnOutcome>,
559}
560
561pub use crate::database::TurnSummary;
562
563/// Configuration and a first prompt to apply once a newly created session's
564/// harness is ready. Served in M2.
565#[derive(Debug, Clone, Default, PartialEq, Eq)]
566pub struct StartFollowup {
567    pub model: Option<String>,
568    pub effort: Option<String>,
569    pub prompt: Option<String>,
570}
571
572/// How far a created session's follow-up has got. Served in M2.
573#[derive(Debug, Clone, PartialEq, Eq)]
574pub enum StartStatus {
575    /// The session is still provisioning, or its harness is not ready.
576    Pending,
577    /// The follow-up prompt was submitted and accepted as this turn.
578    Submitted { turn_id: u64 },
579    /// The session could not be started, or the follow-up could not be applied.
580    Failed { message: String },
581}
582
583/// A page of transcript items, read from the durable projection.
584pub use crate::database::TranscriptPage;
585
586/// A branch the daemon pushed on the caller's behalf.
587#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
588pub struct PushedBranch {
589    pub branch: String,
590    pub remote: String,
591}
592
593/// Which file of the session's workspace to read.
594#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
595pub struct FileQuery {
596    /// Path relative to the session's workspace root.
597    pub path: String,
598}
599
600/// What form the caller wants the session's work in.
601#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
602#[serde(rename_all = "snake_case")]
603pub enum ExportKind {
604    /// A unified diff, as `GET /diff` returns.
605    Patch,
606    /// A branch pushed to the repository's push remote.
607    Branch,
608    /// The git bundle of the session's committed work.
609    Bundle,
610}
611
612#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
613pub struct ExportRequest {
614    pub kind: ExportKind,
615    /// The branch to push. Required when `kind` is `branch`.
616    #[serde(default, skip_serializing_if = "Option::is_none")]
617    pub branch: Option<String>,
618}
619
620/// A git bundle of the session's work.
621#[derive(Debug, Clone)]
622pub struct BundleExport {
623    pub repository: String,
624    pub bytes: Vec<u8>,
625}
626
627/// Why an export could not be produced. Served in M4.
628#[derive(Debug)]
629pub enum ExportError {
630    /// The session is in a state where this export is not possible. The caller
631    /// can act on it, so it answers 409.
632    Refused(String),
633    /// The export was attempted and failed.
634    Failed(anyhow::Error),
635}
636
637impl From<ExportError> for ApiFailure {
638    fn from(error: ExportError) -> Self {
639        match error {
640            ExportError::Refused(message) => Self::conflict(message),
641            ExportError::Failed(error) => Self::from(error),
642        }
643    }
644}
645
646/// Everything the API needs from the daemon: live session actors, the durable
647/// projection, and the target-side git operations.
648///
649/// `mj-controller` cannot depend on the daemon's runtime state, which lives in
650/// `mj-cli`, so the daemon implements this trait and installs it on the server
651/// options. The whole trait is declared now, including the methods later
652/// milestones fill in, so that adding those milestones does not change the
653/// shape every implementation has to match.
654pub trait SubagentBackend: Send + Sync {
655    fn events(
656        &self,
657        filter: crate::database::ApiEventFilter,
658        after_seq: Option<u64>,
659    ) -> BoxFuture<'_, AnyResult<crate::database::ApiEventPage>> {
660        events::load_events(filter, after_seq)
661    }
662
663    fn profile_config(
664        &self,
665        profile: String,
666        model: Option<String>,
667        refresh: bool,
668    ) -> BoxFuture<'_, AnyResult<mj_core::worker_launch::ProfileConfig>> {
669        Box::pin(crate::controller::profile_config::discover(
670            profile, model, refresh,
671        ))
672    }
673    fn start_subagent(
674        &self,
675        _request: crate::controller::RegisterSubagentRequest,
676    ) -> BoxFuture<'_, AnyResult<mj_core::subagent::SubagentRecord>> {
677        Box::pin(async { anyhow::bail!("sub-agent creation is unavailable") })
678    }
679    fn list_subagents(
680        &self,
681        parent_session_id: String,
682    ) -> BoxFuture<'_, AnyResult<Vec<mj_core::subagent::SubagentRecord>>> {
683        Box::pin(async move {
684            tokio::task::spawn_blocking(move || crate::database::list_subagents(&parent_session_id))
685                .await?
686        })
687    }
688    fn read_context_file(
689        &self,
690        session_id: String,
691        path: PathBuf,
692    ) -> BoxFuture<'_, std::result::Result<Vec<u8>, ExportError>> {
693        self.read_file(session_id, path)
694    }
695    fn set_config(
696        &self,
697        session_id: String,
698        key: String,
699        value: String,
700    ) -> BoxFuture<'_, AnyResult<()>> {
701        Box::pin(async move {
702            self.session_handle(session_id)
703                .await?
704                .ok_or_else(|| anyhow::anyhow!("session has no live actor"))?
705                .set_config(key, value)
706                .await
707        })
708    }
709    fn cancel_start(&self, _session_id: String) -> BoxFuture<'_, AnyResult<()>> {
710        Box::pin(async { Ok(()) })
711    }
712    /// The live actor for a session, or `None` when none holds it.
713    fn session_handle(&self, session_id: String)
714    -> BoxFuture<'_, AnyResult<Option<SessionHandle>>>;
715
716    /// Submit a prompt, returning its relay acceptance ordinal.
717    fn prompt(&self, session_id: String, text: String) -> BoxFuture<'_, AnyResult<u64>>;
718
719    /// Durable turn state for a session with no live actor.
720    fn turn_state(&self, session_id: String) -> BoxFuture<'_, AnyResult<Option<TurnState>>>;
721
722    /// Summarize the turn that started at this transcript position.
723    fn turn_summary(
724        &self,
725        session_id: String,
726        turn_start_position: u64,
727    ) -> BoxFuture<'_, AnyResult<TurnSummary>>;
728
729    /// Apply model, effort, and the first prompt once a new session is ready.
730    fn start_followup(
731        &self,
732        session_id: String,
733        followup: StartFollowup,
734    ) -> BoxFuture<'_, AnyResult<()>>;
735
736    /// How far a created session's follow-up has got.
737    fn start_status(&self, session_id: String) -> BoxFuture<'_, AnyResult<Option<StartStatus>>>;
738
739    /// A page of transcript items after `after_seq`.
740    fn transcript(
741        &self,
742        session_id: String,
743        after_seq: u64,
744        limit: usize,
745        role: Option<mj_core::transcript::TranscriptRole>,
746    ) -> BoxFuture<'_, AnyResult<Option<TranscriptPage>>>;
747
748    fn usage(
749        &self,
750        session_id: String,
751        after_seq: u64,
752        limit: usize,
753    ) -> BoxFuture<'_, AnyResult<Option<crate::database::UsagePage>>> {
754        Box::pin(async move {
755            tokio::task::spawn_blocking(move || {
756                crate::database::load_session_usage(&session_id, after_seq, limit)
757            })
758            .await?
759        })
760    }
761
762    /// A unified diff of the session's work.
763    fn diff(&self, session_id: String) -> BoxFuture<'_, Result<String, ExportError>>;
764
765    /// One file from the session's workspace.
766    fn read_file(
767        &self,
768        session_id: String,
769        path: PathBuf,
770    ) -> BoxFuture<'_, Result<Vec<u8>, ExportError>>;
771
772    fn write_file(
773        &self,
774        _session_id: String,
775        _path: PathBuf,
776        _bytes: Vec<u8>,
777        _overwrite: bool,
778    ) -> BoxFuture<'_, Result<(), ExportError>> {
779        Box::pin(async { Err(ExportError::Refused("file injection is unavailable".into())) })
780    }
781
782    /// Push the session's branch to its repository's default remote.
783    fn push_branch(
784        &self,
785        session_id: String,
786        branch: String,
787    ) -> BoxFuture<'_, Result<PushedBranch, ExportError>>;
788
789    /// A git bundle of the session's committed work.
790    fn bundle(&self, session_id: String) -> BoxFuture<'_, Result<BundleExport, ExportError>>;
791}
792
793fn backend(state: &ServerState) -> Result<&Arc<dyn SubagentBackend>, ApiFailure> {
794    state
795        .subagent
796        .as_ref()
797        .ok_or_else(|| ApiFailure::unavailable("this server has no subagent backend installed"))
798}
799
800// ---------------------------------------------------------------------------
801// Wait resolution
802// ---------------------------------------------------------------------------
803
804/// Classify a harness stop reason.
805///
806/// Stop reasons are free text the harness chooses, so the comparison is
807/// case-insensitive and tolerates both `end_turn` and `endTurn`. Anything
808/// unrecognized is an error carrying the raw reason, because silently calling
809/// an unknown ending "finished" would tell the caller its work succeeded when
810/// nobody knows that it did.
811pub fn map_stop_reason(stop_reason: &str) -> (WaitOutcome, Option<String>) {
812    use mj_core::state::{PromptCompletion, classify_prompt_completion};
813
814    match classify_prompt_completion(stop_reason) {
815        PromptCompletion::Finished => (WaitOutcome::Finished, None),
816        PromptCompletion::Cancelled => (WaitOutcome::Cancelled, None),
817        PromptCompletion::QuotaLimit => (WaitOutcome::QuotaLimit, None),
818        PromptCompletion::Error => (WaitOutcome::Error, Some(stop_reason.to_owned())),
819    }
820}
821
822/// Everything one pass of the wait loop knows about a session.
823#[derive(Debug, Clone, Default, PartialEq)]
824pub struct WaitObservation {
825    pub background_work: Option<ApiBackgroundWork>,
826    pub pending_elicitations: Vec<mj_core::elicitation::ElicitationRequest>,
827    pub lifecycle: Option<ViewerLifecycleCategory>,
828    /// A recorded launch failure names this session.
829    pub launch_failed: bool,
830    pub execution: MaterializedExecutionState,
831    pub active_turn: Option<MaterializedTurn>,
832    pub last_turn_outcome: Option<MaterializedTurnOutcome>,
833    pub queued: usize,
834    pub capacity_retry: Option<CapacityRetry>,
835    pub start_status: Option<StartStatus>,
836}
837
838/// What one pass of the wait loop concluded, before the turn summary is read.
839#[derive(Debug, Clone, PartialEq, Eq)]
840pub struct WaitDecision {
841    pub outcome: WaitOutcome,
842    pub stop_reason: Option<String>,
843    pub message: Option<String>,
844    pub turn_id: Option<u64>,
845    /// Where the finished turn began, so its summary can be read.
846    pub turn_start_position: Option<u64>,
847}
848
849impl WaitDecision {
850    fn simple(outcome: WaitOutcome, message: Option<String>) -> Self {
851        Self {
852            outcome,
853            stop_reason: None,
854            message,
855            turn_id: None,
856            turn_start_position: None,
857        }
858    }
859
860    fn from_outcome(outcome: &MaterializedTurnOutcome) -> Self {
861        let (kind, stop_reason, message) = match &outcome.outcome {
862            TurnOutcomeKind::Completed { stop_reason } => {
863                let (kind, message) = map_stop_reason(stop_reason);
864                (
865                    kind,
866                    Some(stop_reason.clone()),
867                    outcome
868                        .diagnostic
869                        .as_ref()
870                        .map(|d| d.message.clone())
871                        .or(message),
872                )
873            }
874            TurnOutcomeKind::Rejected { message } => {
875                (WaitOutcome::Error, None, Some(message.clone()))
876            }
877            TurnOutcomeKind::Interrupted { message } => {
878                (WaitOutcome::Error, None, Some(message.clone()))
879            }
880        };
881        Self {
882            outcome: kind,
883            stop_reason,
884            message,
885            turn_id: outcome.accepted_ordinal,
886            turn_start_position: outcome.turn_start_position,
887        }
888    }
889}
890
891/// Decide whether this observation ends the wait.
892///
893/// A wait answers for one turn, so only the turn's own fate ends it. In
894/// particular a session that is carrying an error from some earlier, unrelated
895/// action is not a reason to fail the turn the caller asked about: the session
896/// error badge has no expiry, and reporting it here made every later wait on
897/// that session return `error` while the turn ran on perfectly well.
898///
899/// The rules run in order, and the order is the point:
900///
901/// 1. A stopped or stopping session ends the wait as `stopped`, superseding
902///    any initialization result that raced with the close request.
903/// 2. A launch failure or failed initialization is reported before a turn; a durable
904///    failed lifecycle ends it as `error` even after a daemon restart.
905/// 3. Otherwise the wait has a target turn: the caller's explicit `turn_id`,
906///    else the turn a create-with-prompt call submitted, else "the newest
907///    one", which additionally requires the session to be idle with an empty
908///    queue — with queued prompts, "idle" alone would return an earlier
909///    prompt's outcome.
910/// 4. A capacity outcome with a retry armed is not an ending: the worker will
911///    submit the retry itself, so the wait keeps waiting.
912///
913/// A turn that really did fail still reports `error`: a rejected or interrupted
914/// turn, and an unrecognized stop reason, all come back through the turn record
915/// in rule 3.
916pub fn resolve_wait(observation: &WaitObservation, request: &WaitRequest) -> Option<WaitDecision> {
917    let stopping = matches!(
918        observation.lifecycle,
919        Some(ViewerLifecycleCategory::Stopped | ViewerLifecycleCategory::Stopping)
920    ) || matches!(
921        observation.execution,
922        MaterializedExecutionState::Closing | MaterializedExecutionState::Closed
923    );
924    if stopping {
925        return Some(WaitDecision::simple(
926            WaitOutcome::Stopped,
927            Some("the session is stopped or stopping".to_owned()),
928        ));
929    }
930    if observation.launch_failed {
931        return Some(WaitDecision::simple(
932            WaitOutcome::Error,
933            Some("the session failed to launch".to_owned()),
934        ));
935    }
936    if let Some(StartStatus::Failed { message }) = &observation.start_status {
937        return Some(WaitDecision::simple(
938            WaitOutcome::Error,
939            Some(message.clone()),
940        ));
941    }
942    if observation.lifecycle == Some(ViewerLifecycleCategory::Failed) {
943        return Some(WaitDecision::simple(
944            WaitOutcome::Error,
945            Some("the session is in a failed state".to_owned()),
946        ));
947    }
948    let retry_pending = |outcome: &MaterializedTurnOutcome| {
949        observation.capacity_retry.is_some()
950            && matches!(
951                &outcome.outcome,
952                TurnOutcomeKind::Completed { stop_reason } if is_capacity_stop_reason(stop_reason)
953            )
954    };
955    let target = request.turn_id.or(match &observation.start_status {
956        Some(StartStatus::Submitted { turn_id }) => Some(*turn_id),
957        _ => None,
958    });
959    let target_finished = target.is_some_and(|target| {
960        observation
961            .last_turn_outcome
962            .as_ref()
963            .is_some_and(|outcome| {
964                outcome
965                    .accepted_ordinal
966                    .is_some_and(|ordinal| ordinal >= target)
967                    && !retry_pending(outcome)
968            })
969    });
970    if request.return_on_input && !target_finished && !observation.pending_elicitations.is_empty() {
971        return Some(WaitDecision {
972            outcome: WaitOutcome::InputRequired,
973            stop_reason: None,
974            message: Some("the harness needs a response to a structured input request".into()),
975            turn_id: observation
976                .active_turn
977                .as_ref()
978                .and_then(|turn| turn.accepted_ordinal),
979            turn_start_position: None,
980        });
981    }
982    match target {
983        Some(target) => {
984            let outcome = observation.last_turn_outcome.as_ref()?;
985            if outcome
986                .accepted_ordinal
987                .is_none_or(|ordinal| ordinal < target)
988            {
989                return None;
990            }
991            if retry_pending(outcome) {
992                return None;
993            }
994            Some(WaitDecision::from_outcome(outcome))
995        }
996        None => {
997            if observation.execution != MaterializedExecutionState::Idle
998                || observation.active_turn.is_some()
999                || observation.queued > 0
1000            {
1001                return None;
1002            }
1003            match observation.last_turn_outcome.as_ref() {
1004                Some(outcome) if retry_pending(outcome) => None,
1005                Some(outcome) => Some(WaitDecision::from_outcome(outcome)),
1006                // Idle with nothing queued and nothing ever finished: there is
1007                // no turn to wait for, so say so immediately rather than block
1008                // for the full timeout.
1009                None => Some(WaitDecision::simple(WaitOutcome::Finished, None)),
1010            }
1011        }
1012    }
1013}
1014
1015// ---------------------------------------------------------------------------
1016// Router
1017// ---------------------------------------------------------------------------
1018
1019pub(super) fn router(state: ServerState) -> Router<ServerState> {
1020    Router::new()
1021        .route("/events", get(events::events))
1022        .route("/profiles/{profile_id}/config", get(profile_config))
1023        .route(
1024            "/sessions/{session_id}/config",
1025            axum::routing::patch(set_config),
1026        )
1027        .route("/sessions", get(list_sessions).post(start_session))
1028        .route("/sessions/{session_id}", get(get_session))
1029        .route(
1030            "/sessions/{session_id}/subagents",
1031            get(list_subagents).post(spawn_subagent),
1032        )
1033        .route("/sessions/{session_id}/prompt", post(prompt))
1034        .route("/sessions/{session_id}/transcript", get(transcript))
1035        .route("/sessions/{session_id}/usage", get(usage))
1036        .route("/sessions/{session_id}/wait", post(wait))
1037        .route("/sessions/{session_id}/close", post(close))
1038        .route("/sessions/{session_id}/cancel-turn", post(cancel_turn))
1039        .route("/sessions/{session_id}/diff", get(diff))
1040        .route(
1041            "/sessions/{session_id}/files",
1042            get(read_file)
1043                .put(write_file)
1044                .layer(axum::extract::DefaultBodyLimit::max(
1045                    mj_checkpoint::archive::MAX_SESSION_FILE_BYTES as usize,
1046                )),
1047        )
1048        .route("/sessions/{session_id}/elicitations", get(elicitations))
1049        .route(
1050            "/sessions/{session_id}/elicitations/{elicitation_id}",
1051            post(respond_elicitation),
1052        )
1053        .route("/sessions/{session_id}/export", post(export))
1054        .route_layer(axum::middleware::from_fn_with_state(
1055            state,
1056            require_api_auth,
1057        ))
1058        // Outside the auth layer so a 401 carries the version header too: a
1059        // client must be able to tell "wrong token" from "wrong server".
1060        .layer(axum::middleware::from_fn(api_response_headers))
1061}
1062
1063/// Accept either the bearer token or the viewer's own session cookie.
1064///
1065/// The cookie is accepted because a browser already signed in to the viewer is
1066/// the same user, and it makes the API reachable from the viewer page without
1067/// handing the page a second secret.
1068async fn require_api_auth(
1069    State(state): State<ServerState>,
1070    request: HttpRequest<axum::body::Body>,
1071    next: Next,
1072) -> Result<Response, ApiFailure> {
1073    let bearer = request
1074        .headers()
1075        .get(AUTHORIZATION)
1076        .and_then(|value| value.to_str().ok())
1077        .and_then(|value| value.strip_prefix("Bearer "))
1078        .map(str::trim);
1079    if bearer.is_some_and(|token| {
1080        constant_time_eq(state.api_token.as_bytes(), token.as_bytes()) && !token.is_empty()
1081    }) {
1082        return Ok(next.run(request).await);
1083    }
1084    let cookie = request
1085        .headers()
1086        .get(COOKIE)
1087        .and_then(|value| value.to_str().ok())
1088        .and_then(|header| cookie_value(header, COOKIE_NAME));
1089    if cookie.is_some_and(|value| session_cookie_valid(&state.cookie_key, value, now_unix())) {
1090        return Ok(next.run(request).await);
1091    }
1092    Err(ApiFailure::new(
1093        StatusCode::UNAUTHORIZED,
1094        "supply the API token from the api-token file as a bearer token",
1095    ))
1096}
1097
1098/// Stamp the contract version and forbid caching on every API response,
1099/// including failures.
1100async fn api_response_headers(request: HttpRequest<axum::body::Body>, next: Next) -> Response {
1101    let mut response = next.run(request).await;
1102    let headers = response.headers_mut();
1103    headers.insert(API_VERSION_HEADER, HeaderValue::from_static(API_VERSION));
1104    headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
1105    response
1106}
1107
1108// ---------------------------------------------------------------------------
1109// Handlers
1110// ---------------------------------------------------------------------------
1111
1112#[derive(Debug, Default, Clone, Serialize, Deserialize)]
1113#[serde(deny_unknown_fields)]
1114pub struct SessionListQuery {
1115    pub workspace_id: Option<String>,
1116}
1117
1118#[derive(Debug, Default, Deserialize)]
1119#[serde(deny_unknown_fields)]
1120struct ProfileConfigQuery {
1121    model: Option<String>,
1122}
1123
1124#[derive(Debug, Clone, Serialize, Deserialize)]
1125#[serde(deny_unknown_fields)]
1126pub struct SetConfigRequest {
1127    pub key: String,
1128    pub value: String,
1129}
1130
1131async fn profile_config(
1132    State(state): State<ServerState>,
1133    Path(profile_id): Path<String>,
1134    Query(query): Query<ProfileConfigQuery>,
1135) -> Result<Json<mj_core::worker_launch::ProfileConfig>, ApiFailure> {
1136    super::require_profile(&state.snapshot_rx.borrow(), &profile_id)?;
1137    let choices = backend(&state)?
1138        .profile_config(profile_id, query.model, false)
1139        .await
1140        .map_err(|error| ApiFailure::unavailable(format!("profile discovery failed: {error:#}")))?;
1141    Ok(Json(choices))
1142}
1143
1144pub(crate) fn validate_selectors(
1145    choices: &mj_core::worker_launch::ProfileConfig,
1146    model: Option<&str>,
1147    effort: Option<&str>,
1148) -> Result<(), ApiFailure> {
1149    for (key, value, offered) in [
1150        ("model", model, &choices.models),
1151        ("effort", effort, &choices.efforts),
1152    ] {
1153        if let Some(value) = value
1154            && !offered.iter().any(|choice| choice.value == value)
1155        {
1156            return Err(ApiFailure::bad_request(format!(
1157                "this profile does not offer {value:?} as {key}; choices: {}",
1158                offered
1159                    .iter()
1160                    .map(|choice| choice.value.as_str())
1161                    .collect::<Vec<_>>()
1162                    .join(", ")
1163            )));
1164        }
1165    }
1166    Ok(())
1167}
1168
1169async fn set_config(
1170    State(state): State<ServerState>,
1171    Path(session_id): Path<String>,
1172    Json(request): Json<SetConfigRequest>,
1173) -> Result<Json<ApiSession>, ApiFailure> {
1174    validate_action(
1175        &ControllerAction::SetConfig {
1176            session_id: session_id.clone(),
1177            key: request.key.clone(),
1178            value: request.value.clone(),
1179        },
1180        &state.snapshot_rx.borrow(),
1181    )?;
1182    let backend = backend(&state)?;
1183    backend
1184        .set_config(session_id.clone(), request.key, request.value)
1185        .await
1186        .map_err(|error| ApiFailure::conflict(format!("configuration failed: {error:#}")))?;
1187    let mut session = ApiSession::from(require_session_record(
1188        &state.snapshot_rx.borrow(),
1189        &session_id,
1190    )?);
1191    if let Some(handle) = backend.session_handle(session_id).await?
1192        && let Some(snapshot) = handle.view().snapshot
1193    {
1194        session.config_options =
1195            super::session_config_view(session.harness_kind.parse()?, &snapshot.operational);
1196    }
1197    Ok(Json(session))
1198}
1199
1200async fn list_sessions(
1201    State(state): State<ServerState>,
1202    Query(query): Query<SessionListQuery>,
1203) -> Result<Json<SessionListResponse>, ApiFailure> {
1204    let snapshot = state.snapshot_rx.borrow();
1205    Ok(Json(SessionListResponse {
1206        sessions: snapshot
1207            .sessions
1208            .iter()
1209            .filter(|session| {
1210                query
1211                    .workspace_id
1212                    .as_ref()
1213                    .is_none_or(|id| &session.workspace_id == id)
1214            })
1215            .map(ApiSession::from)
1216            .collect(),
1217    }))
1218}
1219
1220async fn get_session(
1221    State(state): State<ServerState>,
1222    Path(session_id): Path<String>,
1223) -> Result<Json<ApiSession>, ApiFailure> {
1224    let mut session = {
1225        let snapshot = state.snapshot_rx.borrow();
1226        ApiSession::from(require_session_record(&snapshot, &session_id)?)
1227    };
1228    if let Ok(backend) = backend(&state) {
1229        if let Some(turn) = backend.turn_state(session_id.clone()).await? {
1230            session.last_turn_diagnostic = turn
1231                .last_turn_outcome
1232                .as_ref()
1233                .and_then(|turn| turn.diagnostic.clone());
1234            session.last_turn_outcome = turn.last_turn_outcome.map(api_turn_outcome);
1235        }
1236        if let Some(handle) = backend.session_handle(session_id).await? {
1237            let view = handle.view();
1238            if view.connected
1239                && let Some(snapshot) = view.snapshot
1240            {
1241                session.background_work = Some(ApiBackgroundWork::from(&snapshot.operational));
1242            }
1243        }
1244    }
1245    Ok(Json(session))
1246}
1247
1248/// Create a session, and hand its first prompt to the backend to submit once
1249/// the harness is ready.
1250///
1251/// Creation answers as soon as the controller has published an id, because
1252/// provisioning a target takes minutes and the caller's next call is a wait.
1253/// The prompt is therefore not submitted here; the backend follows the session
1254/// up and records the turn it became, which `wait` reads.
1255async fn start_session(
1256    State(state): State<ServerState>,
1257    Json(request): Json<StartSessionRequest>,
1258) -> Result<(StatusCode, Json<StartSessionResponse>), ApiFailure> {
1259    let backend = backend(&state)?.clone();
1260    if let Some(prompt) = &request.prompt {
1261        validate_prompt_text(prompt, false)?;
1262    }
1263    super::require_profile(&state.snapshot_rx.borrow(), &request.profile_id)?;
1264    super::require_target(&state.snapshot_rx.borrow(), &request.target_id)?;
1265    if request.model.is_some() || request.effort.is_some() {
1266        let mut choices = backend
1267            .profile_config(request.profile_id.clone(), request.model.clone(), false)
1268            .await
1269            .map_err(|error| {
1270                ApiFailure::unavailable(format!("profile discovery failed: {error:#}"))
1271            })?;
1272        if validate_selectors(
1273            &choices,
1274            request.model.as_deref(),
1275            request.effort.as_deref(),
1276        )
1277        .is_err()
1278        {
1279            choices = backend
1280                .profile_config(request.profile_id.clone(), request.model.clone(), true)
1281                .await
1282                .map_err(|error| {
1283                    ApiFailure::unavailable(format!("profile discovery failed: {error:#}"))
1284                })?;
1285        }
1286        validate_selectors(
1287            &choices,
1288            request.model.as_deref(),
1289            request.effort.as_deref(),
1290        )?;
1291    }
1292    let bundle_id = match (&request.bundle_id, &request.project_directory) {
1293        (Some(bundle_id), _) => bundle_id.clone(),
1294        // A caller that names a directory should not have to make a bundle
1295        // first; this is the same quick bundle the viewer's own form creates.
1296        (None, Some(directory)) => {
1297            create_quick_bundle(&state, directory.display().to_string()).await?
1298        }
1299        (None, None) => {
1300            return Err(ApiFailure::bad_request(
1301                "supply bundle_id, project_directory, or both",
1302            ));
1303        }
1304    };
1305    let action = ControllerAction::New {
1306        create_managed_worktree: request.create_managed_worktree,
1307        mjolnir_subagents: request.mjolnir_subagents,
1308        workspace_id: request.workspace_id.clone().unwrap_or_default(),
1309        profile_id: request.profile_id.clone(),
1310        bundle_id,
1311        target_id: request.target_id.clone(),
1312        title: request.title.clone(),
1313        project_directory: request.project_directory.clone(),
1314        dirty_ack: Vec::new(),
1315    };
1316    validate_action(&action, &state.snapshot_rx.borrow())?;
1317
1318    let (reply, outcome) = tokio::sync::oneshot::channel();
1319    state
1320        .action_tx
1321        .send(ControllerRequest { action, reply })
1322        .await
1323        .map_err(|_| ApiFailure::unavailable("the controller is not accepting actions"))?;
1324    let outcome = outcome
1325        .await
1326        .map_err(|_| ApiFailure::unavailable("the controller dropped this action"))?;
1327    if let Some(rejection) = outcome.rejection() {
1328        return Err(rejection.into());
1329    }
1330    let ActionOutcome::Accepted {
1331        session_id: Some(session_id),
1332    } = outcome
1333    else {
1334        return Err(ApiFailure::new(
1335            StatusCode::INTERNAL_SERVER_ERROR,
1336            "the controller accepted the session but published no id",
1337        ));
1338    };
1339
1340    backend
1341        .start_followup(
1342            session_id.clone(),
1343            StartFollowup {
1344                model: request.model,
1345                effort: request.effort,
1346                prompt: request.prompt,
1347            },
1348        )
1349        .await?;
1350    Ok((
1351        StatusCode::CREATED,
1352        Json(StartSessionResponse {
1353            session_id,
1354            turn_id: None,
1355        }),
1356    ))
1357}
1358
1359const MAX_SUBAGENT_CONTEXT_BYTES: usize = 256 * 1024;
1360
1361async fn spawn_subagent(
1362    State(state): State<ServerState>,
1363    Path(parent_session_id): Path<String>,
1364    Json(request): Json<SpawnSubagentRequest>,
1365) -> Result<(StatusCode, Json<SubagentView>), ApiFailure> {
1366    let backend = backend(&state)?.clone();
1367    let parent = {
1368        let snapshot = state.snapshot_rx.borrow();
1369        require_session_record(&snapshot, &parent_session_id)?.clone()
1370    };
1371    if !matches!(parent.harness_kind.as_str(), "claude" | "codex") {
1372        return Err(ApiFailure::conflict(
1373            "only Claude and Codex sessions can spawn sub-agents",
1374        ));
1375    }
1376    validate_prompt_text(&request.instructions, false)?;
1377    if request.task_name.trim().is_empty() {
1378        return Err(ApiFailure::bad_request("task_name cannot be empty"));
1379    }
1380    if request.request_key.trim().is_empty() {
1381        return Err(ApiFailure::bad_request("request_key cannot be empty"));
1382    }
1383    let profile_id = request
1384        .profile_id
1385        .clone()
1386        .unwrap_or_else(|| parent.profile_id.clone());
1387    let mut selected_model = request.model.clone();
1388    let mut selected_effort = request.effort.clone();
1389    if profile_id == parent.profile_id
1390        && (selected_model.is_none() || selected_effort.is_none())
1391        && let Some(handle) = backend.session_handle(parent_session_id.clone()).await?
1392        && let Some(snapshot) = handle.view().snapshot
1393    {
1394        selected_model =
1395            selected_model.or_else(|| snapshot.operational.config.get("model").cloned());
1396        selected_effort =
1397            selected_effort.or_else(|| snapshot.operational.config.get("effort").cloned());
1398    }
1399    if selected_model.is_some() || selected_effort.is_some() {
1400        let choices = backend
1401            .profile_config(profile_id.clone(), selected_model.clone(), false)
1402            .await
1403            .map_err(|error| {
1404                ApiFailure::unavailable(format!("profile discovery failed: {error:#}"))
1405            })?;
1406        validate_selectors(
1407            &choices,
1408            selected_model.as_deref(),
1409            selected_effort.as_deref(),
1410        )?;
1411    }
1412
1413    let initial_prompt = build_subagent_prompt(
1414        &backend,
1415        &parent_session_id,
1416        &request.instructions,
1417        request.context.as_deref(),
1418        &request.files,
1419    )
1420    .await?;
1421    let relation = backend
1422        .start_subagent(crate::controller::RegisterSubagentRequest {
1423            parent_session_id: parent_session_id.clone(),
1424            task_name: request.task_name,
1425            profile_id,
1426            model: selected_model.clone(),
1427            effort: selected_effort.clone(),
1428            working_directory: request.working_directory.unwrap_or_default(),
1429            initial_prompt: initial_prompt.clone(),
1430            request_key: request.request_key,
1431        })
1432        .await
1433        .map_err(|error| ApiFailure::conflict(format!("sub-agent creation failed: {error:#}")))?;
1434    backend
1435        .start_followup(
1436            relation.child_session_id.clone(),
1437            StartFollowup {
1438                model: selected_model,
1439                effort: selected_effort,
1440                prompt: Some(initial_prompt),
1441            },
1442        )
1443        .await?;
1444    let session = {
1445        let snapshot = state.snapshot_rx.borrow();
1446        ApiSession::from(require_session_record(
1447            &snapshot,
1448            &relation.child_session_id,
1449        )?)
1450    };
1451    Ok((
1452        StatusCode::CREATED,
1453        Json(SubagentView {
1454            parent_session_id,
1455            task_name: relation.task_name,
1456            request_key: relation.request_key,
1457            session,
1458        }),
1459    ))
1460}
1461
1462async fn list_subagents(
1463    State(state): State<ServerState>,
1464    Path(parent_session_id): Path<String>,
1465) -> Result<Json<SubagentListResponse>, ApiFailure> {
1466    {
1467        let snapshot = state.snapshot_rx.borrow();
1468        require_session_record(&snapshot, &parent_session_id)?;
1469    }
1470    let records = backend(&state)?
1471        .list_subagents(parent_session_id.clone())
1472        .await?;
1473    let snapshot = state.snapshot_rx.borrow();
1474    let subagents = records
1475        .into_iter()
1476        .map(|record| {
1477            let session = require_session_record(&snapshot, &record.child_session_id)?;
1478            Ok(SubagentView {
1479                parent_session_id: parent_session_id.clone(),
1480                task_name: record.task_name,
1481                request_key: record.request_key,
1482                session: ApiSession::from(session),
1483            })
1484        })
1485        .collect::<Result<Vec<_>, ApiFailure>>()?;
1486    Ok(Json(SubagentListResponse { subagents }))
1487}
1488
1489pub(crate) async fn build_subagent_prompt(
1490    backend: &Arc<dyn SubagentBackend>,
1491    parent_session_id: &str,
1492    instructions: &str,
1493    context: Option<&str>,
1494    ranges: &[SubagentSourceRange],
1495) -> Result<String, ApiFailure> {
1496    let mut prompt = String::new();
1497    prompt.push_str(instructions.trim());
1498    if let Some(context) = context.map(str::trim).filter(|context| !context.is_empty()) {
1499        prompt.push_str("\n\n<parent_context>\n");
1500        prompt.push_str(context);
1501        prompt.push_str("\n</parent_context>");
1502    }
1503    for range in ranges {
1504        if range.file.as_os_str().is_empty()
1505            || range.file.is_absolute()
1506            || range
1507                .file
1508                .components()
1509                .any(|component| matches!(component, Component::ParentDir | Component::Prefix(_)))
1510        {
1511            return Err(ApiFailure::bad_request(format!(
1512                "source path {} must be relative and must not contain '..'",
1513                range.file.display()
1514            )));
1515        }
1516        if range.start == 0 || range.end < range.start {
1517            return Err(ApiFailure::bad_request(format!(
1518                "invalid source range {}:{}-{}; lines are one-based and inclusive",
1519                range.file.display(),
1520                range.start,
1521                range.end
1522            )));
1523        }
1524        let bytes = backend
1525            .read_context_file(parent_session_id.to_owned(), range.file.clone())
1526            .await
1527            .map_err(ApiFailure::from)?;
1528        let text = std::str::from_utf8(&bytes).map_err(|_| {
1529            ApiFailure::bad_request(format!(
1530                "source file {} is not UTF-8 text",
1531                range.file.display()
1532            ))
1533        })?;
1534        let lines = text.lines().collect::<Vec<_>>();
1535        if range.end > lines.len() as u64 {
1536            return Err(ApiFailure::bad_request(format!(
1537                "source range {}:{}-{} exceeds its {} lines",
1538                range.file.display(),
1539                range.start,
1540                range.end,
1541                lines.len()
1542            )));
1543        }
1544        prompt.push_str(&format!(
1545            "\n\n--- source {:?}, lines {}-{} (one-based, inclusive) ---\n",
1546            range.file.to_string_lossy(),
1547            range.start,
1548            range.end
1549        ));
1550        for (offset, line) in lines[(range.start - 1) as usize..range.end as usize]
1551            .iter()
1552            .enumerate()
1553        {
1554            prompt.push_str(&format!("{:>6}  {line}\n", range.start as usize + offset));
1555        }
1556        prompt.push_str("--- end source ---");
1557        if prompt.len() > MAX_SUBAGENT_CONTEXT_BYTES {
1558            return Err(ApiFailure::bad_request(format!(
1559                "sub-agent handoff exceeds the {MAX_SUBAGENT_CONTEXT_BYTES}-byte limit"
1560            )));
1561        }
1562    }
1563    if prompt.len() > MAX_SUBAGENT_CONTEXT_BYTES {
1564        return Err(ApiFailure::bad_request(format!(
1565            "sub-agent handoff exceeds the {MAX_SUBAGENT_CONTEXT_BYTES}-byte limit"
1566        )));
1567    }
1568    Ok(prompt)
1569}
1570
1571async fn prompt(
1572    State(state): State<ServerState>,
1573    Path(session_id): Path<String>,
1574    Json(request): Json<PromptRequest>,
1575) -> Result<(StatusCode, Json<PromptResponse>), ApiFailure> {
1576    let backend = backend(&state)?.clone();
1577    {
1578        let snapshot = state.snapshot_rx.borrow();
1579        let action = ControllerAction::Prompt {
1580            session_id: session_id.clone(),
1581            text: request.text.clone(),
1582            images: Vec::new(),
1583        };
1584        validate_action(&action, &snapshot)?;
1585        let session = require_session_record(&snapshot, &session_id)?;
1586        if !session.capabilities.prompt {
1587            return Err(ApiFailure::conflict(
1588                "this session cannot take a prompt right now",
1589            ));
1590        }
1591    }
1592    let turn_id = backend.prompt(session_id, request.text).await?;
1593    Ok((StatusCode::ACCEPTED, Json(PromptResponse { turn_id })))
1594}
1595
1596/// Page through a session's transcript.
1597///
1598/// It reads the durable projection rather than the live actor, so it answers
1599/// the same way while a session runs and long after it stopped.
1600#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1601pub struct UsageQuery {
1602    pub after_seq: Option<u64>,
1603    pub limit: Option<usize>,
1604}
1605
1606async fn usage(
1607    State(state): State<ServerState>,
1608    Path(session_id): Path<String>,
1609    Query(query): Query<UsageQuery>,
1610) -> Result<Json<crate::database::UsagePage>, ApiFailure> {
1611    let page = backend(&state)?
1612        .usage(
1613            session_id,
1614            query.after_seq.unwrap_or(0),
1615            query.limit.unwrap_or(200).clamp(1, 1000),
1616        )
1617        .await?
1618        .ok_or_else(|| ApiFailure::not_found("no usage history is recorded for that session"))?;
1619    Ok(Json(page))
1620}
1621
1622async fn transcript(
1623    State(state): State<ServerState>,
1624    Path(session_id): Path<String>,
1625    Query(query): Query<TranscriptQuery>,
1626) -> Result<Json<TranscriptResponse>, ApiFailure> {
1627    let backend = backend(&state)?.clone();
1628    let limit = query
1629        .limit
1630        .unwrap_or(DEFAULT_TRANSCRIPT_LIMIT)
1631        .clamp(1, MAX_TRANSCRIPT_LIMIT);
1632    let page = backend
1633        .transcript(
1634            session_id.clone(),
1635            query.after_seq.unwrap_or(0),
1636            limit,
1637            query.role,
1638        )
1639        .await?
1640        .ok_or_else(|| ApiFailure::not_found("no transcript is recorded for that session"))?;
1641    Ok(Json(TranscriptResponse {
1642        next_after_seq: page.next_after_seq,
1643        session_id,
1644        latest_seq: page.latest_seq,
1645        execution: page.execution,
1646        items: page
1647            .items
1648            .iter()
1649            .map(|item| TranscriptItemView {
1650                stable_id: item.stable_id.clone(),
1651                position: item.position,
1652                seq: item.seq(),
1653                role: mj_core::transcript::transcript_item_role(&item.body).to_owned(),
1654                text: mj_transcript::transcript::transcript_item_text(item),
1655                created_at_ms: item.created_at_ms,
1656                last_changed_at_ms: item.last_changed_at_ms,
1657                body: item.body.clone(),
1658            })
1659            .collect(),
1660    }))
1661}
1662
1663async fn close(
1664    State(state): State<ServerState>,
1665    Path(session_id): Path<String>,
1666    request: Option<Json<CloseRequest>>,
1667) -> Result<StatusCode, ApiFailure> {
1668    let force = request.as_ref().is_some_and(|request| request.force);
1669    let active_children = if force {
1670        // A force close destroys the children with the parent, so an active
1671        // child is not a reason to refuse it.
1672        0
1673    } else {
1674        let snapshot = state.snapshot_rx.borrow();
1675        let session = require_session_record(&snapshot, &session_id)?;
1676        session
1677            .subagent_session_ids
1678            .iter()
1679            .filter(|child_id| {
1680                snapshot.sessions.iter().any(|child| {
1681                    child.id == child_id.as_str()
1682                        && !matches!(
1683                            child.state.as_str(),
1684                            "stopped" | "lost" | "error" | "destroyed-with-data-loss"
1685                        )
1686                })
1687            })
1688            .count()
1689    };
1690    if active_children > 0
1691        && !request
1692            .as_ref()
1693            .is_some_and(|request| request.acknowledge_active_subagents)
1694    {
1695        return Err(ApiFailure::conflict(format!(
1696            "session has {} sub-agent(s); retry with acknowledge_active_subagents=true to stop children first",
1697            active_children
1698        )));
1699    }
1700    backend(&state)?.cancel_start(session_id.clone()).await?;
1701    if force {
1702        return send_action(&state, ControllerAction::ForceClose { session_id }).await;
1703    }
1704    send_action(&state, ControllerAction::Close { session_id }).await
1705}
1706
1707#[derive(Debug, Default, serde::Deserialize)]
1708#[serde(deny_unknown_fields)]
1709struct CloseRequest {
1710    #[serde(default)]
1711    acknowledge_active_subagents: bool,
1712    /// Destroy the session instead of checkpointing it. Irreversible.
1713    #[serde(default)]
1714    force: bool,
1715}
1716
1717async fn cancel_turn(
1718    State(state): State<ServerState>,
1719    Path(session_id): Path<String>,
1720) -> Result<StatusCode, ApiFailure> {
1721    send_action(&state, ControllerAction::CancelTurn { session_id }).await
1722}
1723
1724async fn send_action(
1725    state: &ServerState,
1726    action: ControllerAction,
1727) -> Result<StatusCode, ApiFailure> {
1728    validate_action(&action, &state.snapshot_rx.borrow())?;
1729    let (reply, outcome) = tokio::sync::oneshot::channel();
1730    state
1731        .action_tx
1732        .send(ControllerRequest { action, reply })
1733        .await
1734        .map_err(|_| ApiFailure::unavailable("the controller is not accepting actions"))?;
1735    let outcome = outcome
1736        .await
1737        .map_err(|_| ApiFailure::unavailable("the controller dropped this action"))?;
1738    match outcome.rejection() {
1739        Some(rejection) => Err(rejection.into()),
1740        None => Ok(StatusCode::ACCEPTED),
1741    }
1742}
1743
1744/// A unified diff of everything the session changed.
1745async fn diff(
1746    State(state): State<ServerState>,
1747    Path(session_id): Path<String>,
1748) -> Result<Response, ApiFailure> {
1749    let backend = backend(&state)?.clone();
1750    let diff = backend.diff(session_id).await?;
1751    Ok(([(CONTENT_TYPE, "text/x-diff; charset=utf-8")], diff).into_response())
1752}
1753
1754/// One file from the session's workspace, as bytes.
1755///
1756/// The path is checked here as well as on the target: a caller that spells an
1757/// absolute or escaping path has made a mistake worth naming, and there is no
1758/// reason to spend a round trip to the target discovering it.
1759#[derive(Debug, Clone, Serialize, Deserialize)]
1760pub struct WriteFileQuery {
1761    pub path: PathBuf,
1762    #[serde(default)]
1763    pub overwrite: bool,
1764}
1765
1766#[derive(Debug, Clone, Serialize, Deserialize)]
1767pub struct WriteFileResponse {
1768    pub path: PathBuf,
1769    pub bytes: usize,
1770}
1771
1772async fn write_file(
1773    State(state): State<ServerState>,
1774    Path(session_id): Path<String>,
1775    Query(query): Query<WriteFileQuery>,
1776    bytes: axum::body::Bytes,
1777) -> Result<Json<WriteFileResponse>, ApiFailure> {
1778    mj_core::config::validate_relative_destination(&query.path)
1779        .map_err(|error| ApiFailure::bad_request(format!("{error:#}")))?;
1780    {
1781        let snapshot = state.snapshot_rx.borrow();
1782        let session = require_session_record(&snapshot, &session_id)?;
1783        if !session.is_idle || session.lifecycle != ViewerLifecycleCategory::Live {
1784            return Err(ApiFailure::conflict(
1785                "session must be live and idle for file injection",
1786            ));
1787        }
1788    }
1789    let count = bytes.len();
1790    backend(&state)?
1791        .write_file(
1792            session_id,
1793            query.path.clone(),
1794            bytes.to_vec(),
1795            query.overwrite,
1796        )
1797        .await?;
1798    Ok(Json(WriteFileResponse {
1799        path: query.path,
1800        bytes: count,
1801    }))
1802}
1803
1804async fn elicitations(
1805    State(state): State<ServerState>,
1806    Path(session_id): Path<String>,
1807) -> Result<Json<Vec<mj_core::elicitation::ElicitationRequest>>, ApiFailure> {
1808    let snapshot = state.snapshot_rx.borrow();
1809    Ok(Json(
1810        require_session_record(&snapshot, &session_id)?
1811            .pending_elicitations
1812            .clone(),
1813    ))
1814}
1815
1816async fn respond_elicitation(
1817    State(state): State<ServerState>,
1818    Path((session_id, elicitation_id)): Path<(String, String)>,
1819    Json(response): Json<mj_core::elicitation::ElicitationResponse>,
1820) -> Result<StatusCode, ApiFailure> {
1821    send_action(
1822        &state,
1823        ControllerAction::RespondElicitation {
1824            session_id,
1825            elicitation_id,
1826            response,
1827        },
1828    )
1829    .await
1830}
1831
1832async fn read_file(
1833    State(state): State<ServerState>,
1834    Path(session_id): Path<String>,
1835    Query(query): Query<FileQuery>,
1836) -> Result<Response, ApiFailure> {
1837    let backend = backend(&state)?.clone();
1838    let path = PathBuf::from(&query.path);
1839    if query.path.trim().is_empty()
1840        || path.is_absolute()
1841        || path
1842            .components()
1843            .any(|component| matches!(component, Component::ParentDir | Component::Prefix(_)))
1844    {
1845        return Err(ApiFailure::bad_request(
1846            "path must be relative to the session workspace and must not contain '..'",
1847        ));
1848    }
1849    let bytes = backend.read_file(session_id, path).await?;
1850    Ok(([(CONTENT_TYPE, "application/octet-stream")], bytes).into_response())
1851}
1852
1853/// Get the session's work out, in whichever form the caller asked for.
1854async fn export(
1855    State(state): State<ServerState>,
1856    Path(session_id): Path<String>,
1857    Json(request): Json<ExportRequest>,
1858) -> Result<Response, ApiFailure> {
1859    let backend = backend(&state)?.clone();
1860    match request.kind {
1861        ExportKind::Patch => {
1862            let diff = backend.diff(session_id).await?;
1863            Ok(([(CONTENT_TYPE, "text/x-diff; charset=utf-8")], diff).into_response())
1864        }
1865        ExportKind::Branch => {
1866            let branch = request
1867                .branch
1868                .as_deref()
1869                .map(str::trim)
1870                .filter(|branch| !branch.is_empty())
1871                .ok_or_else(|| ApiFailure::bad_request("a branch export needs a branch name"))?
1872                .to_owned();
1873            let pushed = backend.push_branch(session_id, branch).await?;
1874            Ok(Json(pushed).into_response())
1875        }
1876        ExportKind::Bundle => {
1877            let bundle = backend.bundle(session_id.clone()).await?;
1878            // The filename reaches a header, so keep it to characters that
1879            // cannot end the quoted string or split the response.
1880            let filename: String = format!("{session_id}-{}.bundle", bundle.repository)
1881                .chars()
1882                .map(|character| match character {
1883                    'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '-' | '_' => character,
1884                    _ => '-',
1885                })
1886                .collect();
1887            Ok((
1888                [
1889                    (CONTENT_TYPE, "application/octet-stream".to_owned()),
1890                    (
1891                        CONTENT_DISPOSITION,
1892                        format!("attachment; filename=\"{filename}\""),
1893                    ),
1894                ],
1895                bundle.bytes,
1896            )
1897                .into_response())
1898        }
1899    }
1900}
1901
1902async fn wait(
1903    State(state): State<ServerState>,
1904    Path(session_id): Path<String>,
1905    Json(request): Json<WaitRequest>,
1906) -> Result<Json<WaitResponse>, ApiFailure> {
1907    let timeout = request.timeout_secs.unwrap_or(DEFAULT_WAIT_SECS);
1908    if timeout == 0 || timeout > MAX_WAIT_SECS {
1909        return Err(ApiFailure::bad_request(format!(
1910            "timeout_secs must be between 1 and {MAX_WAIT_SECS}"
1911        )));
1912    }
1913    let backend = backend(&state)?.clone();
1914    {
1915        let snapshot = state.snapshot_rx.borrow();
1916        require_session_record(&snapshot, &session_id)?;
1917    }
1918    let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout);
1919    let mut snapshot_rx = state.snapshot_rx.clone();
1920    let mut handle = backend.session_handle(session_id.clone()).await?;
1921
1922    loop {
1923        let start_status = backend.start_status(session_id.clone()).await?;
1924        let live = handle.as_ref().map(SessionHandle::view);
1925        let relay = live.as_ref().map(RelayHealth::from);
1926        let durable = match live.as_ref().and_then(|view| view.snapshot.as_ref()) {
1927            Some(_) => None,
1928            None => backend.turn_state(session_id.clone()).await?,
1929        };
1930        let (session_facts, observation) = {
1931            let snapshot = snapshot_rx.borrow();
1932            let session = require_session_record(&snapshot, &session_id)?;
1933            let observation = build_observation(
1934                &snapshot,
1935                session,
1936                live.as_ref(),
1937                durable.as_ref(),
1938                start_status,
1939            );
1940            (ApiSession::from(session), observation)
1941        };
1942        if let Some(decision) = resolve_wait(&observation, &request) {
1943            return Ok(Json(
1944                finish_wait(
1945                    &backend,
1946                    &session_id,
1947                    session_facts,
1948                    observation,
1949                    decision,
1950                    relay,
1951                )
1952                .await?,
1953            ));
1954        }
1955
1956        let changed = async {
1957            match handle.as_mut() {
1958                Some(handle) => {
1959                    let _ = handle.changed().await;
1960                }
1961                // No live actor: durable state is the only thing that moves,
1962                // and it is not a channel, so poll it.
1963                None => tokio::time::sleep(STOPPED_POLL_INTERVAL).await,
1964            }
1965        };
1966        tokio::select! {
1967            () = changed => {}
1968            // A closed snapshot channel means the control loop that publishes
1969            // session facts is gone. Ignoring the error would spin this loop,
1970            // because a closed watch reports "changed" immediately and forever.
1971            published = snapshot_rx.changed() => {
1972                if published.is_err() {
1973                    return Err(ApiFailure::unavailable(
1974                        "the controller stopped publishing session state",
1975                    ));
1976                }
1977            }
1978            () = tokio::time::sleep_until(deadline) => {
1979                let snapshot = snapshot_rx.borrow();
1980                let session = require_session_record(&snapshot, &session_id)?;
1981                return Ok(Json(WaitResponse {
1982                           diagnostic: None,
1983                    pending_elicitations: Vec::new(),
1984                    usage: None,
1985                    outcome: WaitOutcome::Timeout,
1986                    stop_reason: None,
1987                    message: Some(format!("the turn was still running after {timeout} seconds")),
1988                    final_message: None,
1989                    turn_id: request.turn_id.or_else(|| {
1990                        observation.active_turn.as_ref().and_then(|turn| turn.accepted_ordinal)
1991                    }),
1992                    turn_number: None,
1993                    elapsed_ms: None,
1994                    capacity_retry: observation.capacity_retry.as_ref().map(WaitCapacityRetry::from),
1995                    relay,
1996                    session: ApiSession::from(session),
1997                }));
1998            }
1999            () = state.shutdown.cancelled() => {
2000                return Err(ApiFailure::unavailable("the server is shutting down"));
2001            }
2002        }
2003        // A stopped actor stops publishing; re-acquire so a session that was
2004        // replaced or resumed under us is followed rather than waited on
2005        // forever.
2006        if handle.as_ref().is_some_and(SessionHandle::is_stopped) {
2007            handle = backend.session_handle(session_id.clone()).await?;
2008        }
2009    }
2010}
2011
2012fn build_observation(
2013    snapshot: &ViewerSnapshot,
2014    session: &ViewerSession,
2015    live: Option<&mj_client::session::ManagedSessionView>,
2016    durable: Option<&TurnState>,
2017    start_status: Option<StartStatus>,
2018) -> WaitObservation {
2019    let mut observation = WaitObservation {
2020        pending_elicitations: session.pending_elicitations.clone(),
2021        lifecycle: Some(session.lifecycle),
2022        launch_failed: snapshot
2023            .launch_failures
2024            .iter()
2025            .any(|failure| failure.session_id.as_deref() == Some(session.id.as_str())),
2026        capacity_retry: session.capacity_retry.clone(),
2027        start_status,
2028        ..WaitObservation::default()
2029    };
2030    if let Some(view) = live
2031        && view.connected
2032        && let Some(snapshot) = &view.snapshot
2033    {
2034        observation.background_work = Some(ApiBackgroundWork::from(&snapshot.operational));
2035    }
2036    if let Some(snapshot) = live.and_then(|view| view.snapshot.as_ref()) {
2037        observation
2038            .pending_elicitations
2039            .clone_from(&snapshot.materialized.pending_elicitations);
2040        observation.execution = snapshot.materialized.execution;
2041        observation.active_turn = snapshot.materialized.active_turn.clone();
2042        observation
2043            .last_turn_outcome
2044            .clone_from(&snapshot.materialized.last_turn_outcome);
2045        observation.queued = snapshot.materialized.queued_prompts.len();
2046        observation
2047            .capacity_retry
2048            .clone_from(&snapshot.operational.capacity_retry);
2049    } else if let Some(durable) = durable {
2050        observation.execution = durable.execution;
2051        observation.active_turn = durable.active_turn.clone();
2052        observation
2053            .last_turn_outcome
2054            .clone_from(&durable.last_turn_outcome);
2055    }
2056    observation
2057}
2058
2059// Older v1 clients reject unknown fields inside this shared turn type. Usage
2060// travels in the new top-level wait field and the dedicated usage endpoint.
2061fn api_turn_outcome(mut turn: MaterializedTurnOutcome) -> MaterializedTurnOutcome {
2062    turn.usage = None;
2063    turn.diagnostic = None;
2064    turn
2065}
2066
2067async fn finish_wait(
2068    backend: &Arc<dyn SubagentBackend>,
2069    session_id: &str,
2070    mut session: ApiSession,
2071    observation: WaitObservation,
2072    decision: WaitDecision,
2073    relay: Option<RelayHealth>,
2074) -> Result<WaitResponse, ApiFailure> {
2075    session
2076        .background_work
2077        .clone_from(&observation.background_work);
2078    session
2079        .last_turn_outcome
2080        .clone_from(&observation.last_turn_outcome);
2081    session.last_turn_diagnostic = session
2082        .last_turn_outcome
2083        .as_ref()
2084        .and_then(|turn| turn.diagnostic.clone());
2085    session.last_turn_outcome = session.last_turn_outcome.map(api_turn_outcome);
2086    let summary = match decision.turn_start_position {
2087        Some(position) => Some(
2088            backend
2089                .turn_summary(session_id.to_owned(), position)
2090                .await?,
2091        ),
2092        None => None,
2093    };
2094    Ok(WaitResponse {
2095        diagnostic: observation
2096            .last_turn_outcome
2097            .as_ref()
2098            .filter(|turn| {
2099                turn.turn_start_position.is_some()
2100                    && turn.turn_start_position == decision.turn_start_position
2101            })
2102            .and_then(|turn| turn.diagnostic.clone()),
2103        pending_elicitations: if decision.outcome == WaitOutcome::InputRequired {
2104            observation.pending_elicitations.clone()
2105        } else {
2106            Vec::new()
2107        },
2108        usage: observation
2109            .last_turn_outcome
2110            .as_ref()
2111            .filter(|turn| {
2112                turn.turn_start_position.is_some()
2113                    && turn.turn_start_position == decision.turn_start_position
2114            })
2115            .and_then(|turn| turn.usage.clone()),
2116        outcome: decision.outcome,
2117        stop_reason: decision.stop_reason,
2118        message: decision.message,
2119        final_message: summary
2120            .as_ref()
2121            .and_then(|summary| summary.final_message.clone()),
2122        turn_id: decision.turn_id,
2123        turn_number: summary.as_ref().map(|summary| summary.turn_number),
2124        elapsed_ms: summary
2125            .as_ref()
2126            .map(|summary| summary.last_changed_at_ms - summary.turn_started_at_ms),
2127        capacity_retry: observation
2128            .capacity_retry
2129            .as_ref()
2130            .map(WaitCapacityRetry::from),
2131        relay,
2132        session,
2133    })
2134}
2135
2136#[cfg(test)]
2137mod tests {
2138    use super::*;
2139    use std::collections::BTreeMap;
2140    use std::sync::Mutex;
2141
2142    use axum::body::Body;
2143    use axum::http::Request;
2144    use axum::http::header::{CONTENT_DISPOSITION, CONTENT_TYPE, SET_COOKIE};
2145    use http_body_util::BodyExt as _;
2146    use tokio::sync::{mpsc, watch};
2147    use tower::ServiceExt as _;
2148
2149    use super::super::{
2150        ControllerRequest, ServerOptions, ServerRequests, ViewerSnapshot, router,
2151        tests::sample_config_state,
2152    };
2153
2154    fn error_event(seq: u64) -> crate::database::ApiEvent {
2155        crate::database::ApiEvent {
2156            seq,
2157            session_id: "session-1".into(),
2158            recorded_at_ms: 10,
2159            event: crate::database::ApiEventData::Error {
2160                message: "test failure".into(),
2161                command_id: None,
2162            },
2163        }
2164    }
2165
2166    #[tokio::test]
2167    async fn bundle_export_distinguishes_deferral_from_failure() {
2168        for fails in [false, true] {
2169            let (app, _actions, _snapshots, _bundles) = api_app(
2170                Arc::new(FakeBackend {
2171                    bundle_fails: fails,
2172                    ..Default::default()
2173                }),
2174                |_| {},
2175            );
2176            let response = app
2177                .oneshot(
2178                    bearer(Request::post("/api/v1/sessions/session-1/export"))
2179                        .header(CONTENT_TYPE, "application/json")
2180                        .body(Body::from(r#"{"kind":"bundle"}"#))
2181                        .unwrap(),
2182                )
2183                .await
2184                .unwrap();
2185            assert_eq!(
2186                response.status(),
2187                if fails {
2188                    StatusCode::INTERNAL_SERVER_ERROR
2189                } else {
2190                    StatusCode::CONFLICT
2191                }
2192            );
2193        }
2194    }
2195
2196    #[tokio::test]
2197    async fn wait_reports_background_knowledge_without_claiming_checkpoint_readiness() {
2198        let root = tempfile::tempdir().unwrap();
2199        let relay =
2200            mj_worker::relay::DurableRelay::open(root.path(), "session-1", "1.0.0").unwrap();
2201        let materialized = mj_core::state::MaterializedSession::empty("session-1");
2202        let mut live = mj_client::session::ManagedSessionView {
2203            connected: true,
2204            error: None,
2205            snapshot: Some(mj_core::state::ManagedSessionSnapshot {
2206                subagent_requests: Vec::new(),
2207                subagent_results: Vec::new(),
2208                window: mj_core::state::ProjectionWindow::of(&materialized),
2209                materialized,
2210                operational: relay.operational_state(),
2211                latest_credential_sync_signal: None,
2212                worker_build: None,
2213            }),
2214        };
2215        let (config, state) = sample_config_state();
2216        let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
2217        let session = &snapshot.sessions[0];
2218        let backend: Arc<dyn SubagentBackend> = Arc::new(FakeBackend::default());
2219        for known in [None, Some(false), Some(true)] {
2220            live.snapshot
2221                .as_mut()
2222                .unwrap()
2223                .operational
2224                .background_work_known = known;
2225            let observation = build_observation(&snapshot, session, Some(&live), None, None);
2226            let decision = resolve_wait(&observation, &WaitRequest::default()).unwrap();
2227            let response = finish_wait(
2228                &backend,
2229                &session.id,
2230                ApiSession::from(session),
2231                observation,
2232                decision,
2233                None,
2234            )
2235            .await
2236            .unwrap();
2237            assert_eq!(response.session.background_work.unwrap().known, known);
2238        }
2239        live.snapshot
2240            .as_mut()
2241            .unwrap()
2242            .operational
2243            .background_commands
2244            .push(mj_core::relay::BackgroundCommand {
2245                id: "task-1".into(),
2246                started_at_ms: 1,
2247                command: "background agent".into(),
2248                can_stop: false,
2249            });
2250        let observation = build_observation(&snapshot, session, Some(&live), None, None);
2251        assert_eq!(observation.background_work.unwrap().tasks[0].id, "task-1");
2252        live.connected = false;
2253        assert!(
2254            build_observation(&snapshot, session, Some(&live), None, None)
2255                .background_work
2256                .is_none()
2257        );
2258    }
2259
2260    #[tokio::test]
2261    async fn event_stream_replays_then_follows_live_events_with_version_and_ids() {
2262        let backend = Arc::new(FakeBackend::default());
2263        backend
2264            .events
2265            .lock()
2266            .unwrap()
2267            .extend([error_event(1), error_event(2)]);
2268        let (app, _actions, _snapshots, _bundles) = api_app(backend.clone(), |_| {});
2269        let response = app
2270            .oneshot(
2271                bearer(Request::get(
2272                    "/api/v1/events?session_id=session-1&workspace_id=default",
2273                ))
2274                .header("Last-Event-ID", "1")
2275                .body(Body::empty())
2276                .unwrap(),
2277            )
2278            .await
2279            .unwrap();
2280        assert_eq!(response.status(), StatusCode::OK);
2281        assert_eq!(response.headers()[API_VERSION_HEADER], API_VERSION);
2282        assert_eq!(response.headers()[CONTENT_TYPE], "text/event-stream");
2283        let mut body = response.into_body();
2284        let frame = tokio::time::timeout(Duration::from_secs(2), body.frame())
2285            .await
2286            .unwrap()
2287            .unwrap()
2288            .unwrap();
2289        let text = std::str::from_utf8(frame.data_ref().unwrap()).unwrap();
2290        assert!(text.contains("id: 2"), "{text}");
2291        assert!(text.contains("event: error"), "{text}");
2292        backend.events.lock().unwrap().push(error_event(3));
2293        let frame = tokio::time::timeout(Duration::from_secs(2), body.frame())
2294            .await
2295            .unwrap()
2296            .unwrap()
2297            .unwrap();
2298        assert!(
2299            std::str::from_utf8(frame.data_ref().unwrap())
2300                .unwrap()
2301                .contains("id: 3")
2302        );
2303        let queries = backend.event_queries.lock().unwrap();
2304        assert_eq!(queries[0].0.workspace_id.as_deref(), Some("default"));
2305        assert_eq!(queries[0].1, Some(1));
2306    }
2307
2308    #[tokio::test]
2309    async fn event_stream_rejects_an_unknown_session_instead_of_waiting_forever() {
2310        let backend = Arc::new(FakeBackend::default());
2311        let (app, _actions, _snapshots, _bundles) = api_app(backend.clone(), |_| {});
2312        let response = app
2313            .oneshot(
2314                bearer(Request::get(
2315                    "/api/v1/events?session_id=session-that-never-existed",
2316                ))
2317                .body(Body::empty())
2318                .unwrap(),
2319            )
2320            .await
2321            .unwrap();
2322
2323        assert_eq!(response.status(), StatusCode::NOT_FOUND);
2324        assert!(backend.event_queries.lock().unwrap().is_empty());
2325    }
2326
2327    #[tokio::test]
2328    async fn event_stream_slow_readers_do_not_block_requests_or_shutdown() {
2329        let backend = Arc::new(FakeBackend::default());
2330        backend.events.lock().unwrap().extend((1..=200).map(|seq| {
2331            let mut event = error_event(seq);
2332            event.event = crate::database::ApiEventData::Error {
2333                message: "x".repeat(8192),
2334                command_id: None,
2335            };
2336            event
2337        }));
2338        let (app, _actions, _snapshots, _bundles) = api_app(backend.clone(), |_| {});
2339        let stream = app
2340            .clone()
2341            .oneshot(
2342                bearer(Request::get("/api/v1/events?after_seq=0"))
2343                    .body(Body::empty())
2344                    .unwrap(),
2345            )
2346            .await
2347            .unwrap();
2348        // Fill the bounded delivery channel while leaving the stream unread.
2349        tokio::task::yield_now().await;
2350        let response = tokio::time::timeout(
2351            Duration::from_secs(2),
2352            app.oneshot(
2353                bearer(Request::get("/api/v1/sessions"))
2354                    .body(Body::empty())
2355                    .unwrap(),
2356            ),
2357        )
2358        .await
2359        .unwrap()
2360        .unwrap();
2361        assert_eq!(response.status(), StatusCode::OK);
2362        backend.shutdown.cancel();
2363        let body = tokio::time::timeout(Duration::from_secs(2), stream.into_body().collect())
2364            .await
2365            .unwrap()
2366            .unwrap()
2367            .to_bytes();
2368        assert!(
2369            body.len() < 200 * 8192,
2370            "shutdown must not drain the entire unread history"
2371        );
2372    }
2373
2374    #[tokio::test]
2375    async fn event_stream_without_cursor_starts_at_the_current_frontier() {
2376        let backend = Arc::new(FakeBackend::default());
2377        backend.events.lock().unwrap().push(error_event(1));
2378        let (app, _actions, _snapshots, _bundles) = api_app(backend.clone(), |_| {});
2379        let response = app
2380            .oneshot(
2381                bearer(Request::get("/api/v1/events"))
2382                    .body(Body::empty())
2383                    .unwrap(),
2384            )
2385            .await
2386            .unwrap();
2387        backend.events.lock().unwrap().push(error_event(2));
2388        let mut body = response.into_body();
2389        let frame = tokio::time::timeout(Duration::from_secs(2), body.frame())
2390            .await
2391            .unwrap()
2392            .unwrap()
2393            .unwrap();
2394        assert!(
2395            std::str::from_utf8(frame.data_ref().unwrap())
2396                .unwrap()
2397                .contains("id: 2")
2398        );
2399    }
2400
2401    #[tokio::test]
2402    async fn event_stream_rejects_bad_cursors_and_requires_authentication() {
2403        let backend = Arc::new(FakeBackend::default());
2404        backend.events.lock().unwrap().push(error_event(1));
2405        let (app, _actions, _snapshots, _bundles) = api_app(backend, |_| {});
2406        for (uri, header) in [
2407            ("/api/v1/events?after_seq=0", "1"),
2408            ("/api/v1/events", "invalid"),
2409            ("/api/v1/events?after_seq=2", "2"),
2410            ("/api/v1/events", "18446744073709551615"),
2411        ] {
2412            let response = app
2413                .clone()
2414                .oneshot(
2415                    bearer(Request::get(uri))
2416                        .header("Last-Event-ID", header)
2417                        .body(Body::empty())
2418                        .unwrap(),
2419                )
2420                .await
2421                .unwrap();
2422            assert_eq!(
2423                response.status(),
2424                StatusCode::BAD_REQUEST,
2425                "{uri}, {header}"
2426            );
2427        }
2428        let response = app
2429            .oneshot(Request::get("/api/v1/events").body(Body::empty()).unwrap())
2430            .await
2431            .unwrap();
2432        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
2433    }
2434
2435    /// A hand-written backend. Mocking the trait would only re-state its
2436    /// signature; this returns the exact observations each test needs and
2437    /// records what the handlers asked for.
2438    #[derive(Default)]
2439    struct FakeBackend {
2440        /// Successive answers to `turn_state`, newest last. The final entry
2441        /// repeats once exhausted, so a wait loop settles rather than spinning.
2442        turn_states: Mutex<Vec<Option<TurnState>>>,
2443        prompt_ordinal: u64,
2444        prompts: Mutex<Vec<(String, String)>>,
2445        summary: Option<TurnSummary>,
2446        /// Follow-ups the start handler asked for.
2447        followups: Mutex<Vec<(String, StartFollowup)>>,
2448        start_status: Option<StartStatus>,
2449        /// The page and the limit the transcript handler asked for.
2450        transcript: Mutex<Option<TranscriptPage>>,
2451        transcript_limits: Mutex<Vec<usize>>,
2452        /// Export answers. `None` stands for a refusal, which is what an
2453        /// export that cannot be produced looks like to a handler.
2454        diff: Option<String>,
2455        file: Option<Vec<u8>>,
2456        pushed: Option<PushedBranch>,
2457        bundle: Option<BundleExport>,
2458        /// When set, the diff fails outright rather than being refused.
2459        diff_fails: bool,
2460        bundle_fails: bool,
2461        /// The path the file handler asked the backend for.
2462        file_paths: Mutex<Vec<PathBuf>>,
2463        file_writes: Mutex<Vec<(PathBuf, Vec<u8>, bool)>>,
2464        events: Mutex<Vec<crate::database::ApiEvent>>,
2465        shutdown: tokio_util::sync::CancellationToken,
2466        event_queries: Mutex<Vec<(crate::database::ApiEventFilter, Option<u64>)>>,
2467    }
2468
2469    impl FakeBackend {
2470        fn next_turn_state(&self) -> Option<TurnState> {
2471            let mut states = self.turn_states.lock().unwrap();
2472            if states.len() > 1 {
2473                states.remove(0)
2474            } else {
2475                states.first().cloned().flatten()
2476            }
2477        }
2478    }
2479
2480    impl SubagentBackend for FakeBackend {
2481        fn events(
2482            &self,
2483            filter: crate::database::ApiEventFilter,
2484            after_seq: Option<u64>,
2485        ) -> BoxFuture<'_, AnyResult<crate::database::ApiEventPage>> {
2486            Box::pin(async move {
2487                self.event_queries
2488                    .lock()
2489                    .unwrap()
2490                    .push((filter.clone(), after_seq));
2491                let events = self.events.lock().unwrap();
2492                let latest_seq = events.last().map_or(0, |e| e.seq);
2493                let cursor = after_seq.unwrap_or(latest_seq);
2494                let page: Vec<_> = events
2495                    .iter()
2496                    .filter(|e| {
2497                        e.seq > cursor
2498                            && filter
2499                                .session_id
2500                                .as_ref()
2501                                .is_none_or(|id| id == &e.session_id)
2502                    })
2503                    .take(200)
2504                    .cloned()
2505                    .collect();
2506                Ok(crate::database::ApiEventPage {
2507                    next_after_seq: page.last().map_or(latest_seq.max(cursor), |e| e.seq),
2508                    latest_seq,
2509                    events: page,
2510                })
2511            })
2512        }
2513
2514        fn profile_config(
2515            &self,
2516            _profile: String,
2517            _model: Option<String>,
2518            _refresh: bool,
2519        ) -> BoxFuture<'_, AnyResult<mj_core::worker_launch::ProfileConfig>> {
2520            Box::pin(async {
2521                Ok(mj_core::worker_launch::ProfileConfig {
2522                    model: Some("kimi-code/k3".into()),
2523                    models: vec![mj_core::acp::SessionConfigChoice {
2524                        value: "kimi-code/k3".into(),
2525                        name: "K3".into(),
2526                        description: None,
2527                    }],
2528                    efforts: vec![mj_core::acp::SessionConfigChoice {
2529                        value: "high".into(),
2530                        name: "High".into(),
2531                        description: None,
2532                    }],
2533                    observed_at: 1,
2534                })
2535            })
2536        }
2537
2538        fn session_handle(
2539            &self,
2540            _session_id: String,
2541        ) -> BoxFuture<'_, AnyResult<Option<SessionHandle>>> {
2542            Box::pin(async { Ok(None) })
2543        }
2544        fn prompt(&self, session_id: String, text: String) -> BoxFuture<'_, AnyResult<u64>> {
2545            Box::pin(async move {
2546                self.prompts.lock().unwrap().push((session_id, text));
2547                Ok(self.prompt_ordinal)
2548            })
2549        }
2550        fn turn_state(&self, _session_id: String) -> BoxFuture<'_, AnyResult<Option<TurnState>>> {
2551            Box::pin(async { Ok(self.next_turn_state()) })
2552        }
2553        fn turn_summary(
2554            &self,
2555            _session_id: String,
2556            _turn_start_position: u64,
2557        ) -> BoxFuture<'_, AnyResult<TurnSummary>> {
2558            Box::pin(async {
2559                self.summary
2560                    .clone()
2561                    .context("this fake has no turn summary")
2562            })
2563        }
2564        fn start_followup(
2565            &self,
2566            session_id: String,
2567            followup: StartFollowup,
2568        ) -> BoxFuture<'_, AnyResult<()>> {
2569            Box::pin(async move {
2570                self.followups.lock().unwrap().push((session_id, followup));
2571                Ok(())
2572            })
2573        }
2574        fn start_status(
2575            &self,
2576            _session_id: String,
2577        ) -> BoxFuture<'_, AnyResult<Option<StartStatus>>> {
2578            Box::pin(async { Ok(self.start_status.clone()) })
2579        }
2580        fn transcript(
2581            &self,
2582            _session_id: String,
2583            _after_seq: u64,
2584            limit: usize,
2585            _role: Option<mj_core::transcript::TranscriptRole>,
2586        ) -> BoxFuture<'_, AnyResult<Option<TranscriptPage>>> {
2587            Box::pin(async move {
2588                self.transcript_limits.lock().unwrap().push(limit);
2589                Ok(self.transcript.lock().unwrap().clone())
2590            })
2591        }
2592        fn diff(&self, _session_id: String) -> BoxFuture<'_, Result<String, ExportError>> {
2593            Box::pin(async {
2594                if self.diff_fails {
2595                    return Err(ExportError::Failed(anyhow::anyhow!("git exploded")));
2596                }
2597                self.diff
2598                    .clone()
2599                    .ok_or_else(|| ExportError::Refused("this session has no live target".into()))
2600            })
2601        }
2602        fn read_file(
2603            &self,
2604            _session_id: String,
2605            path: PathBuf,
2606        ) -> BoxFuture<'_, Result<Vec<u8>, ExportError>> {
2607            Box::pin(async move {
2608                self.file_paths.lock().unwrap().push(path);
2609                self.file
2610                    .clone()
2611                    .ok_or_else(|| ExportError::Refused("this session has no live target".into()))
2612            })
2613        }
2614        fn write_file(
2615            &self,
2616            _session_id: String,
2617            path: PathBuf,
2618            bytes: Vec<u8>,
2619            overwrite: bool,
2620        ) -> BoxFuture<'_, Result<(), ExportError>> {
2621            Box::pin(async move {
2622                self.file_writes
2623                    .lock()
2624                    .unwrap()
2625                    .push((path, bytes, overwrite));
2626                Ok(())
2627            })
2628        }
2629        fn push_branch(
2630            &self,
2631            _session_id: String,
2632            branch: String,
2633        ) -> BoxFuture<'_, Result<PushedBranch, ExportError>> {
2634            Box::pin(async move {
2635                self.pushed
2636                    .clone()
2637                    .map(|pushed| PushedBranch { branch, ..pushed })
2638                    .ok_or_else(|| ExportError::Refused("this session is running a turn".into()))
2639            })
2640        }
2641        fn bundle(&self, _session_id: String) -> BoxFuture<'_, Result<BundleExport, ExportError>> {
2642            Box::pin(async {
2643                if self.bundle_fails {
2644                    return Err(ExportError::Failed(anyhow::anyhow!(
2645                        "checkpoint storage failed"
2646                    )));
2647                }
2648                self.bundle.clone().ok_or_else(|| {
2649                    ExportError::Refused("no commits beyond the session base".into())
2650                })
2651            })
2652        }
2653    }
2654
2655    /// Returns the snapshot sender alongside the router: dropping it closes the
2656    /// watch channel, which the wait loop correctly treats as the controller
2657    /// going away.
2658    fn api_app(
2659        backend: Arc<FakeBackend>,
2660        adjust: impl FnOnce(&mut ViewerSnapshot),
2661    ) -> (
2662        axum::Router,
2663        mpsc::Receiver<ControllerRequest>,
2664        watch::Sender<ViewerSnapshot>,
2665        mpsc::Receiver<super::super::BundleRequest>,
2666    ) {
2667        let (config, state) = sample_config_state();
2668        // The sample record carries a recorded error. It is left in place: a
2669        // session-scoped error must not answer a wait about one turn, so every
2670        // wait test below runs against a session that is carrying one.
2671        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
2672        adjust(&mut snapshot);
2673        let (snapshot_tx, snapshot_rx) = watch::channel(snapshot);
2674        let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
2675        let (action_tx, action_rx) = mpsc::channel(8);
2676        let (bundle_tx, bundle_rx) = mpsc::channel(8);
2677        let (receipt_tx, _receipt_rx) = mpsc::channel(8);
2678        let (preflight_tx, _preflight_rx) = mpsc::channel(8);
2679        let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(8);
2680        let (client_state_tx, _client_state_rx) = mpsc::channel(8);
2681        let (dictation_tx, _dictation_rx) = mpsc::channel(8);
2682        let mut options = ServerOptions::new(
2683            "127.0.0.1:0".parse().unwrap(),
2684            snapshot_rx,
2685            conversation_rx,
2686            ServerRequests {
2687                action_tx,
2688                bundle_tx,
2689                receipt_tx,
2690                preflight_tx,
2691                move_preparation_tx,
2692                client_state_tx,
2693                dictation_tx,
2694            },
2695        )
2696        .unwrap()
2697        .with_test_credentials("123456", b"01234567890123456789012345678901");
2698        options.shutdown = backend.shutdown.clone();
2699        options.set_subagent_backend(backend);
2700        (router(options), action_rx, snapshot_tx, bundle_rx)
2701    }
2702
2703    fn bearer(request: axum::http::request::Builder) -> axum::http::request::Builder {
2704        request.header(AUTHORIZATION, "Bearer test-api-token")
2705    }
2706
2707    async fn login_cookie(app: &axum::Router) -> String {
2708        let response = app
2709            .clone()
2710            .oneshot(
2711                Request::post("/auth/session")
2712                    .header(CONTENT_TYPE, "application/json")
2713                    .body(Body::from(r#"{"code":"123456"}"#))
2714                    .unwrap(),
2715            )
2716            .await
2717            .unwrap();
2718        assert_eq!(response.status(), StatusCode::NO_CONTENT);
2719        response
2720            .headers()
2721            .get(SET_COOKIE)
2722            .unwrap()
2723            .to_str()
2724            .unwrap()
2725            .split(';')
2726            .next()
2727            .unwrap()
2728            .to_owned()
2729    }
2730
2731    async fn json_body(response: Response) -> serde_json::Value {
2732        let body = response.into_body().collect().await.unwrap().to_bytes();
2733        serde_json::from_slice(&body).unwrap()
2734    }
2735
2736    #[tokio::test]
2737    async fn the_api_refuses_an_unauthenticated_caller_and_still_names_its_version() {
2738        let (app, _actions, _snapshot_tx, _bundles) =
2739            api_app(Arc::new(FakeBackend::default()), |_| {});
2740
2741        let response = app
2742            .clone()
2743            .oneshot(
2744                Request::get("/api/v1/sessions")
2745                    .body(Body::empty())
2746                    .unwrap(),
2747            )
2748            .await
2749            .unwrap();
2750        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
2751        assert_eq!(
2752            response.headers().get(API_VERSION_HEADER).unwrap(),
2753            API_VERSION,
2754            "a client must be able to tell a wrong token from a wrong server"
2755        );
2756        assert_eq!(response.headers().get(CACHE_CONTROL).unwrap(), "no-store");
2757
2758        let response = app
2759            .clone()
2760            .oneshot(
2761                Request::get("/api/v1/sessions")
2762                    .header(AUTHORIZATION, "Bearer wrong-token")
2763                    .body(Body::empty())
2764                    .unwrap(),
2765            )
2766            .await
2767            .unwrap();
2768        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
2769    }
2770
2771    #[tokio::test]
2772    async fn workspace_filter_excludes_other_workspaces() {
2773        let (app, _, _, _) = api_app(Arc::new(FakeBackend::default()), |snapshot| {
2774            snapshot.sessions[0].workspace_id = "mine".into();
2775            let mut other = snapshot.sessions[0].clone();
2776            other.id = "other".into();
2777            other.workspace_id = "theirs".into();
2778            snapshot.sessions.push(other);
2779        });
2780        let response = app
2781            .oneshot(
2782                bearer(Request::get("/api/v1/sessions?workspace_id=mine"))
2783                    .body(Body::empty())
2784                    .unwrap(),
2785            )
2786            .await
2787            .unwrap();
2788        let body = json_body(response).await;
2789        assert_eq!(body["sessions"].as_array().unwrap().len(), 1);
2790        assert_eq!(body["sessions"][0]["id"], "session-1");
2791    }
2792
2793    #[tokio::test]
2794    async fn invalid_model_is_rejected_before_bundling_or_provisioning() {
2795        let backend = Arc::new(FakeBackend::default());
2796        let (app, mut actions, _, mut bundles) = api_app(backend.clone(), |_| {});
2797        let response = app.oneshot(start_request(r#"{"profile_id":"codex-1","target_id":"raw","project_directory":"/work/hel","model":"k3"}"#.into())).await.unwrap();
2798        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
2799        assert!(
2800            json_body(response).await["error"]
2801                .as_str()
2802                .unwrap()
2803                .contains("kimi-code/k3")
2804        );
2805        assert!(actions.try_recv().is_err());
2806        assert!(bundles.try_recv().is_err());
2807        assert!(backend.followups.lock().unwrap().is_empty());
2808    }
2809
2810    #[test]
2811    fn closing_supersedes_a_failed_initial_configuration() {
2812        let observation = WaitObservation {
2813            lifecycle: Some(ViewerLifecycleCategory::Stopping),
2814            start_status: Some(StartStatus::Failed {
2815                message: "bad model".into(),
2816            }),
2817            ..Default::default()
2818        };
2819        assert_eq!(
2820            resolve_wait(&observation, &WaitRequest::default())
2821                .unwrap()
2822                .outcome,
2823            WaitOutcome::Stopped
2824        );
2825    }
2826
2827    #[tokio::test]
2828    async fn either_the_bearer_token_or_the_viewer_cookie_lists_sessions() {
2829        let (app, _actions, _snapshot_tx, _bundles) =
2830            api_app(Arc::new(FakeBackend::default()), |_| {});
2831        let cookie = login_cookie(&app).await;
2832
2833        for request in [
2834            bearer(Request::get("/api/v1/sessions")),
2835            Request::get("/api/v1/sessions").header(COOKIE, cookie),
2836        ] {
2837            let response = app
2838                .clone()
2839                .oneshot(request.body(Body::empty()).unwrap())
2840                .await
2841                .unwrap();
2842            assert_eq!(response.status(), StatusCode::OK);
2843            assert_eq!(
2844                response.headers().get(API_VERSION_HEADER).unwrap(),
2845                API_VERSION
2846            );
2847            let body = json_body(response).await;
2848            assert_eq!(body["sessions"][0]["id"], "session-1");
2849        }
2850    }
2851
2852    #[tokio::test]
2853    async fn one_session_is_readable_by_id_and_an_unknown_one_is_not_found() {
2854        let (app, _actions, _snapshot_tx, _bundles) =
2855            api_app(Arc::new(FakeBackend::default()), |_| {});
2856
2857        let response = app
2858            .clone()
2859            .oneshot(
2860                bearer(Request::get("/api/v1/sessions/session-1"))
2861                    .body(Body::empty())
2862                    .unwrap(),
2863            )
2864            .await
2865            .unwrap();
2866        assert_eq!(response.status(), StatusCode::OK);
2867        assert_eq!(json_body(response).await["id"], "session-1");
2868
2869        let response = app
2870            .oneshot(
2871                bearer(Request::get("/api/v1/sessions/session-9"))
2872                    .body(Body::empty())
2873                    .unwrap(),
2874            )
2875            .await
2876            .unwrap();
2877        assert_eq!(response.status(), StatusCode::NOT_FOUND);
2878    }
2879
2880    #[tokio::test]
2881    async fn a_prompt_is_validated_before_it_reaches_the_backend() {
2882        // The sample session cannot take a prompt: the capability is the
2883        // server's own answer to "is this session ready", so it must refuse
2884        // before submitting anything.
2885        let backend = Arc::new(FakeBackend::default());
2886        let (app, _actions, _snapshot_tx, _bundles) = api_app(backend.clone(), |_| {});
2887        let response = app
2888            .oneshot(
2889                bearer(Request::post("/api/v1/sessions/session-1/prompt"))
2890                    .header(CONTENT_TYPE, "application/json")
2891                    .body(Body::from(r#"{"text":"go"}"#))
2892                    .unwrap(),
2893            )
2894            .await
2895            .unwrap();
2896        assert_eq!(response.status(), StatusCode::CONFLICT);
2897        assert!(backend.prompts.lock().unwrap().is_empty());
2898
2899        let backend = Arc::new(FakeBackend {
2900            prompt_ordinal: 17,
2901            ..FakeBackend::default()
2902        });
2903        let (app, _actions, _snapshot_tx, _bundles) = api_app(backend.clone(), |snapshot| {
2904            snapshot.sessions[0].capabilities.prompt = true;
2905        });
2906
2907        let response = app
2908            .clone()
2909            .oneshot(
2910                bearer(Request::post("/api/v1/sessions/session-1/prompt"))
2911                    .header(CONTENT_TYPE, "application/json")
2912                    .body(Body::from(r#"{"text":"!ls"}"#))
2913                    .unwrap(),
2914            )
2915            .await
2916            .unwrap();
2917        assert_eq!(
2918            response.status(),
2919            StatusCode::BAD_REQUEST,
2920            "a leading ! is a shell command, not a prompt"
2921        );
2922        assert!(backend.prompts.lock().unwrap().is_empty());
2923
2924        let response = app
2925            .oneshot(
2926                bearer(Request::post("/api/v1/sessions/session-1/prompt"))
2927                    .header(CONTENT_TYPE, "application/json")
2928                    .body(Body::from(r#"{"text":"add a README line"}"#))
2929                    .unwrap(),
2930            )
2931            .await
2932            .unwrap();
2933        assert_eq!(response.status(), StatusCode::ACCEPTED);
2934        assert_eq!(json_body(response).await["turn_id"], 17);
2935        assert_eq!(
2936            backend.prompts.lock().unwrap().as_slice(),
2937            [("session-1".to_owned(), "add a README line".to_owned())]
2938        );
2939    }
2940
2941    fn start_body(extra: &str) -> String {
2942        format!(r#"{{"profile_id":"codex-1","target_id":"podman","bundle_id":"hel"{extra}}}"#)
2943    }
2944
2945    fn start_request(body: String) -> Request<Body> {
2946        bearer(Request::post("/api/v1/sessions"))
2947            .header(CONTENT_TYPE, "application/json")
2948            .body(Body::from(body))
2949            .unwrap()
2950    }
2951
2952    #[tokio::test]
2953    async fn start_returns_the_created_session_and_hands_its_prompt_to_the_followup() {
2954        let backend = Arc::new(FakeBackend::default());
2955        let (app, mut actions, _snapshot_tx, _bundles) = api_app(backend.clone(), |_| {});
2956
2957        let response = tokio::spawn(app.oneshot(start_request(start_body(
2958            r#","prompt":"add a README line""#,
2959        ))));
2960        let request = actions.recv().await.unwrap();
2961        assert_eq!(
2962            request.action,
2963            ControllerAction::New {
2964                mjolnir_subagents: None,
2965                create_managed_worktree: None,
2966                workspace_id: String::new(),
2967                profile_id: "codex-1".into(),
2968                bundle_id: "hel".into(),
2969                target_id: "podman".into(),
2970                title: None,
2971                project_directory: None,
2972                dirty_ack: Vec::new(),
2973            }
2974        );
2975        request
2976            .reply
2977            .send(ActionOutcome::Accepted {
2978                session_id: Some("session-2".into()),
2979            })
2980            .unwrap();
2981
2982        let response = response.await.unwrap().unwrap();
2983        assert_eq!(response.status(), StatusCode::CREATED);
2984        assert_eq!(json_body(response).await["session_id"], "session-2");
2985        let followups = backend.followups.lock().unwrap();
2986        assert_eq!(followups.len(), 1);
2987        assert_eq!(followups[0].0, "session-2");
2988        assert_eq!(
2989            followups[0].1.prompt.as_deref(),
2990            Some("add a README line"),
2991            "the first prompt is the backend's to submit once the harness is ready"
2992        );
2993    }
2994
2995    #[tokio::test]
2996    async fn start_rejects_a_request_that_still_sends_an_idempotency_key() {
2997        let backend = Arc::new(FakeBackend::default());
2998        let (app, mut actions, _snapshot_tx, _bundles) = api_app(backend.clone(), |_| {});
2999
3000        let response = app
3001            .oneshot(start_request(start_body(r#","idempotency_key":"key-1""#)))
3002            .await
3003            .unwrap();
3004        assert_eq!(
3005            response.status(),
3006            StatusCode::UNPROCESSABLE_ENTITY,
3007            "the field is gone, so the body no longer parses"
3008        );
3009        let body = response.into_body().collect().await.unwrap().to_bytes();
3010        let body = String::from_utf8_lossy(&body);
3011        assert!(
3012            body.contains("idempotency_key"),
3013            "the refusal must name the field it did not expect: {body}"
3014        );
3015        assert!(
3016            actions.try_recv().is_err(),
3017            "a request that does not parse must not reach the controller"
3018        );
3019        assert!(backend.followups.lock().unwrap().is_empty());
3020    }
3021
3022    #[tokio::test]
3023    async fn start_refuses_a_shell_command_as_a_first_prompt() {
3024        let backend = Arc::new(FakeBackend::default());
3025        let (app, mut actions, _snapshot_tx, _bundles) = api_app(backend.clone(), |_| {});
3026
3027        let response = app
3028            .oneshot(start_request(start_body(r#","prompt":"!ls""#)))
3029            .await
3030            .unwrap();
3031        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
3032        assert!(actions.try_recv().is_err());
3033        assert!(backend.followups.lock().unwrap().is_empty());
3034    }
3035
3036    #[tokio::test]
3037    async fn a_project_directory_without_a_bundle_creates_the_quick_bundle_first() {
3038        let backend = Arc::new(FakeBackend::default());
3039        let (app, mut actions, _snapshot_tx, mut bundles) = api_app(backend, |_| {});
3040
3041        let response = tokio::spawn(
3042            app.oneshot(start_request(
3043                r#"{"profile_id":"codex-1","target_id":"raw","project_directory":"/work/hel"}"#
3044                    .to_owned(),
3045            )),
3046        );
3047
3048        let bundle = bundles.recv().await.unwrap();
3049        assert_eq!(bundle.source, "/work/hel");
3050        bundle.reply.send(Ok("hel".to_owned())).unwrap();
3051
3052        let request = actions.recv().await.unwrap();
3053        assert_eq!(
3054            request.action,
3055            ControllerAction::New {
3056                mjolnir_subagents: None,
3057                create_managed_worktree: None,
3058                workspace_id: String::new(),
3059                profile_id: "codex-1".into(),
3060                bundle_id: "hel".into(),
3061                target_id: "raw".into(),
3062                title: None,
3063                project_directory: Some(PathBuf::from("/work/hel")),
3064                dirty_ack: Vec::new(),
3065            }
3066        );
3067        request
3068            .reply
3069            .send(ActionOutcome::Accepted {
3070                session_id: Some("session-2".into()),
3071            })
3072            .unwrap();
3073        assert_eq!(
3074            response.await.unwrap().unwrap().status(),
3075            StatusCode::CREATED
3076        );
3077    }
3078
3079    #[tokio::test]
3080    async fn the_transcript_clamps_its_limit_and_reads_items_as_text() {
3081        let backend = Arc::new(FakeBackend {
3082            transcript: Mutex::new(Some(TranscriptPage {
3083                next_after_seq: 9,
3084                items: vec![Arc::new(mj_core::transcript::TranscriptItem {
3085                    stable_id: "item-1".into(),
3086                    position: 4,
3087                    latest_content_event_ordinal: Some(9),
3088                    created_at_ms: 10,
3089                    last_changed_at_ms: 20,
3090                    body: mj_core::transcript::TranscriptBody::Agent {
3091                        chunks: vec![
3092                            serde_json::json!({"content": {"type": "text", "text": "added "}}),
3093                            serde_json::json!({"content": {"type": "text", "text": "the line"}}),
3094                        ],
3095                        streaming: false,
3096                    },
3097                })],
3098                latest_seq: 9,
3099                execution: MaterializedExecutionState::Idle,
3100            })),
3101            ..FakeBackend::default()
3102        });
3103        let (app, _actions, _snapshot_tx, _bundles) = api_app(backend.clone(), |_| {});
3104
3105        let response = app
3106            .oneshot(
3107                bearer(Request::get(
3108                    "/api/v1/sessions/session-1/transcript?after_seq=3&limit=5000",
3109                ))
3110                .body(Body::empty())
3111                .unwrap(),
3112            )
3113            .await
3114            .unwrap();
3115        assert_eq!(response.status(), StatusCode::OK);
3116        let body = json_body(response).await;
3117        assert_eq!(body["latest_seq"], 9);
3118        assert_eq!(
3119            body["items"][0]["seq"], 9,
3120            "an agent message pages by its latest content, not by where it started"
3121        );
3122        assert_eq!(body["items"][0]["role"], "agent");
3123        assert_eq!(
3124            body["items"][0]["text"], "added the line",
3125            "a reading caller gets the message, not its chunks"
3126        );
3127        assert_eq!(body["items"][0]["body"]["kind"], "agent");
3128        assert_eq!(
3129            backend.transcript_limits.lock().unwrap().as_slice(),
3130            [MAX_TRANSCRIPT_LIMIT],
3131            "an oversized limit is clamped rather than refused"
3132        );
3133    }
3134
3135    #[tokio::test]
3136    async fn a_session_with_no_projection_row_has_no_transcript() {
3137        let (app, _actions, _snapshot_tx, _bundles) =
3138            api_app(Arc::new(FakeBackend::default()), |_| {});
3139        let response = app
3140            .oneshot(
3141                bearer(Request::get("/api/v1/sessions/session-1/transcript"))
3142                    .body(Body::empty())
3143                    .unwrap(),
3144            )
3145            .await
3146            .unwrap();
3147        assert_eq!(response.status(), StatusCode::NOT_FOUND);
3148    }
3149
3150    #[tokio::test]
3151    async fn close_and_cancel_turn_reach_the_controller_as_typed_actions() {
3152        let (app, mut actions, _snapshot_tx, _bundles) =
3153            api_app(Arc::new(FakeBackend::default()), |snapshot| {
3154                snapshot.sessions[0].capabilities.cancel_turn = true;
3155            });
3156
3157        for (path, expected) in [
3158            (
3159                "/api/v1/sessions/session-1/close",
3160                ControllerAction::Close {
3161                    session_id: "session-1".into(),
3162                },
3163            ),
3164            (
3165                "/api/v1/sessions/session-1/cancel-turn",
3166                ControllerAction::CancelTurn {
3167                    session_id: "session-1".into(),
3168                },
3169            ),
3170        ] {
3171            let response = tokio::spawn(
3172                app.clone()
3173                    .oneshot(bearer(Request::post(path)).body(Body::empty()).unwrap()),
3174            );
3175            let request = actions.recv().await.unwrap();
3176            assert_eq!(request.action, expected);
3177            request
3178                .reply
3179                .send(super::super::ActionOutcome::accepted())
3180                .unwrap();
3181            let response = response.await.unwrap().unwrap();
3182            assert_eq!(response.status(), StatusCode::ACCEPTED);
3183        }
3184    }
3185
3186    #[tokio::test]
3187    async fn a_forced_close_reaches_the_controller_as_a_force_close_action() {
3188        let (app, mut actions, _snapshot_tx, _bundles) =
3189            api_app(Arc::new(FakeBackend::default()), |_| {});
3190
3191        let response = tokio::spawn(
3192            app.oneshot(
3193                bearer(Request::post("/api/v1/sessions/session-1/close"))
3194                    .header(CONTENT_TYPE, "application/json")
3195                    .body(Body::from(r#"{"force":true}"#))
3196                    .unwrap(),
3197            ),
3198        );
3199        let request = actions.recv().await.unwrap();
3200        assert_eq!(
3201            request.action,
3202            ControllerAction::ForceClose {
3203                session_id: "session-1".into(),
3204            }
3205        );
3206        request
3207            .reply
3208            .send(super::super::ActionOutcome::accepted())
3209            .unwrap();
3210        let response = response.await.unwrap().unwrap();
3211        assert_eq!(response.status(), StatusCode::ACCEPTED);
3212    }
3213
3214    #[tokio::test]
3215    async fn a_forced_close_ignores_active_subagents_that_refuse_a_plain_close() {
3216        let adjust = |snapshot: &mut ViewerSnapshot| {
3217            let mut child = snapshot.sessions[0].clone();
3218            child.id = "child-1".into();
3219            child.state = "running".into();
3220            child.subagent_session_ids.clear();
3221            snapshot.sessions[0].subagent_session_ids = vec!["child-1".into()];
3222            snapshot.sessions.push(child);
3223        };
3224
3225        let (app, _actions, _snapshot_tx, _bundles) =
3226            api_app(Arc::new(FakeBackend::default()), adjust);
3227        let response = app
3228            .oneshot(
3229                bearer(Request::post("/api/v1/sessions/session-1/close"))
3230                    .body(Body::empty())
3231                    .unwrap(),
3232            )
3233            .await
3234            .unwrap();
3235        assert_eq!(response.status(), StatusCode::CONFLICT);
3236
3237        let (app, mut actions, _snapshot_tx, _bundles) =
3238            api_app(Arc::new(FakeBackend::default()), adjust);
3239        let response = tokio::spawn(
3240            app.oneshot(
3241                bearer(Request::post("/api/v1/sessions/session-1/close"))
3242                    .header(CONTENT_TYPE, "application/json")
3243                    .body(Body::from(r#"{"force":true}"#))
3244                    .unwrap(),
3245            ),
3246        );
3247        let request = actions.recv().await.unwrap();
3248        assert_eq!(
3249            request.action,
3250            ControllerAction::ForceClose {
3251                session_id: "session-1".into(),
3252            }
3253        );
3254        request
3255            .reply
3256            .send(super::super::ActionOutcome::accepted())
3257            .unwrap();
3258        let response = response.await.unwrap().unwrap();
3259        assert_eq!(response.status(), StatusCode::ACCEPTED);
3260    }
3261
3262    #[test]
3263    fn a_force_close_is_not_wire_representable() {
3264        // The browser viewer posts this enum to `/actions`, so a wire request
3265        // must not be able to ask for the destructive variant.
3266        assert!(
3267            serde_json::from_str::<ControllerAction>(
3268                r#"{"action":"force-close","session_id":"s"}"#
3269            )
3270            .is_err()
3271        );
3272    }
3273
3274    #[tokio::test]
3275    async fn cancel_turn_is_refused_when_there_is_no_turn_to_cancel() {
3276        let (app, _actions, _snapshot_tx, _bundles) =
3277            api_app(Arc::new(FakeBackend::default()), |_| {});
3278        let response = app
3279            .oneshot(
3280                bearer(Request::post("/api/v1/sessions/session-1/cancel-turn"))
3281                    .body(Body::empty())
3282                    .unwrap(),
3283            )
3284            .await
3285            .unwrap();
3286        assert_eq!(response.status(), StatusCode::CONFLICT);
3287    }
3288
3289    #[tokio::test(start_paused = true)]
3290    async fn wait_returns_the_named_turn_s_outcome_once_the_backend_publishes_it() {
3291        let backend = Arc::new(FakeBackend {
3292            turn_states: Mutex::new(vec![
3293                Some(TurnState {
3294                    execution: MaterializedExecutionState::Running { started_at_ms: 10 },
3295                    active_turn: Some(MaterializedTurn {
3296                        command_id: "prompt-1".into(),
3297                        accepted_ordinal: Some(5),
3298                        turn_start_position: 6,
3299                        started_at_ms: 10,
3300                    }),
3301                    last_turn_outcome: None,
3302                }),
3303                Some(TurnState {
3304                    execution: MaterializedExecutionState::Idle,
3305                    active_turn: None,
3306                    last_turn_outcome: Some(MaterializedTurnOutcome {
3307                        diagnostic: None,
3308                        usage: Some(mj_core::usage::TokenUsage::from_acp(
3309                            mj_core::config::HarnessKind::Codex,
3310                            agent_client_protocol::schema::v1::Usage::new(30, 20, 10),
3311                        )),
3312                        command_id: "prompt-1".into(),
3313                        accepted_ordinal: Some(5),
3314                        turn_start_position: Some(6),
3315                        completed_ordinal: 9,
3316                        completed_at_ms: 900,
3317                        outcome: TurnOutcomeKind::Completed {
3318                            stop_reason: "end_turn".into(),
3319                        },
3320                    }),
3321                }),
3322            ]),
3323            summary: Some(TurnSummary {
3324                turn_number: 3,
3325                turn_started_at_ms: 100,
3326                last_changed_at_ms: 900,
3327                final_message: Some("added the line".into()),
3328            }),
3329            ..FakeBackend::default()
3330        });
3331        let (app, _actions, _snapshot_tx, _bundles) = api_app(backend, |_| {});
3332
3333        let response = app
3334            .oneshot(
3335                bearer(Request::post("/api/v1/sessions/session-1/wait"))
3336                    .header(CONTENT_TYPE, "application/json")
3337                    .body(Body::from(r#"{"turn_id":5}"#))
3338                    .unwrap(),
3339            )
3340            .await
3341            .unwrap();
3342        assert_eq!(response.status(), StatusCode::OK);
3343        let body = json_body(response).await;
3344        assert_eq!(body["outcome"], "finished");
3345        assert_eq!(body["turn_id"], 5);
3346        assert_eq!(body["turn_number"], 3);
3347        assert_eq!(body["elapsed_ms"], 800);
3348        assert_eq!(body["final_message"], "added the line");
3349        assert_eq!(body["stop_reason"], "end_turn");
3350        assert_eq!(body["usage"]["scope"], "last_request");
3351        assert_eq!(body["usage"]["total_tokens"], 30);
3352        assert!(body["usage"].get("thought_tokens").is_none());
3353        assert!(body["session"]["last_turn_outcome"].get("usage").is_none());
3354    }
3355
3356    #[tokio::test(start_paused = true)]
3357    async fn wait_preserves_quota_diagnostic_without_scheduling_retry() {
3358        let diagnostic = mj_core::diagnostic::TurnDiagnostic::from_provider(&serde_json::json!({
3359            "code":"provider.auth_error", "message":"Five-hour usage limit exceeded; resets at 23:00 UTC.",
3360            "details":{"statusCode":403,"resetAt":"23:00 UTC"}
3361        })).unwrap();
3362        let mut turn = completed(5, "QuotaLimit");
3363        turn.diagnostic = Some(diagnostic.clone());
3364        let backend = Arc::new(FakeBackend {
3365            turn_states: Mutex::new(vec![Some(TurnState {
3366                execution: MaterializedExecutionState::Idle,
3367                active_turn: None,
3368                last_turn_outcome: Some(turn),
3369            })]),
3370            summary: Some(TurnSummary {
3371                turn_number: 1,
3372                turn_started_at_ms: 100,
3373                last_changed_at_ms: 500,
3374                final_message: None,
3375            }),
3376            ..FakeBackend::default()
3377        });
3378        let (app, _actions, _snapshot_tx, _bundles) = api_app(backend, |_| {});
3379        let response = app
3380            .oneshot(
3381                bearer(Request::post("/api/v1/sessions/session-1/wait"))
3382                    .header(CONTENT_TYPE, "application/json")
3383                    .body(Body::from(r#"{"turn_id":5}"#))
3384                    .unwrap(),
3385            )
3386            .await
3387            .unwrap();
3388        assert_eq!(response.status(), StatusCode::OK);
3389        let body = json_body(response).await;
3390        assert_eq!(body["outcome"], "quota_limit");
3391        assert_eq!(body["message"], diagnostic.message);
3392        assert_eq!(body["diagnostic"]["http_status"], 403);
3393        assert_eq!(body["diagnostic"]["reset_at"], "23:00 UTC");
3394        assert_eq!(body["session"]["last_turn_diagnostic"], body["diagnostic"]);
3395        assert!(body["capacity_retry"].is_null());
3396        assert!(
3397            body["session"]["last_turn_outcome"]
3398                .get("diagnostic")
3399                .is_none()
3400        );
3401    }
3402
3403    #[tokio::test(start_paused = true)]
3404    async fn wait_reports_a_timeout_rather_than_guessing_at_a_running_turn() {
3405        let backend = Arc::new(FakeBackend {
3406            turn_states: Mutex::new(vec![Some(TurnState {
3407                execution: MaterializedExecutionState::Running { started_at_ms: 10 },
3408                active_turn: Some(MaterializedTurn {
3409                    command_id: "prompt-1".into(),
3410                    accepted_ordinal: Some(5),
3411                    turn_start_position: 6,
3412                    started_at_ms: 10,
3413                }),
3414                last_turn_outcome: None,
3415            })]),
3416            ..FakeBackend::default()
3417        });
3418        let (app, _actions, _snapshot_tx, _bundles) = api_app(backend, |_| {});
3419
3420        let response = app
3421            .oneshot(
3422                bearer(Request::post("/api/v1/sessions/session-1/wait"))
3423                    .header(CONTENT_TYPE, "application/json")
3424                    .body(Body::from(r#"{"turn_id":5,"timeout_secs":2}"#))
3425                    .unwrap(),
3426            )
3427            .await
3428            .unwrap();
3429        assert_eq!(response.status(), StatusCode::OK);
3430        let body = json_body(response).await;
3431        assert_eq!(body["outcome"], "timeout");
3432        assert_eq!(body["turn_id"], 5);
3433    }
3434
3435    #[tokio::test]
3436    async fn wait_refuses_a_timeout_outside_its_bounds() {
3437        let (app, _actions, _snapshot_tx, _bundles) =
3438            api_app(Arc::new(FakeBackend::default()), |_| {});
3439        for body in [r#"{"timeout_secs":0}"#, r#"{"timeout_secs":100000}"#] {
3440            let response = app
3441                .clone()
3442                .oneshot(
3443                    bearer(Request::post("/api/v1/sessions/session-1/wait"))
3444                        .header(CONTENT_TYPE, "application/json")
3445                        .body(Body::from(body))
3446                        .unwrap(),
3447                )
3448                .await
3449                .unwrap();
3450            assert_eq!(response.status(), StatusCode::BAD_REQUEST);
3451        }
3452    }
3453
3454    #[test]
3455    fn stop_reasons_map_to_outcomes_and_unknown_ones_stay_visible() {
3456        assert_eq!(map_stop_reason("end_turn"), (WaitOutcome::Finished, None));
3457        assert_eq!(map_stop_reason("EndTurn"), (WaitOutcome::Finished, None));
3458        assert_eq!(map_stop_reason("cancelled"), (WaitOutcome::Cancelled, None));
3459        assert_eq!(
3460            map_stop_reason("ModelCapacity"),
3461            (WaitOutcome::QuotaLimit, None)
3462        );
3463        assert_eq!(
3464            map_stop_reason("refusal"),
3465            (WaitOutcome::Error, Some("refusal".to_owned())),
3466            "an unrecognized ending must not be reported as success"
3467        );
3468    }
3469
3470    fn completed(accepted_ordinal: u64, stop_reason: &str) -> MaterializedTurnOutcome {
3471        MaterializedTurnOutcome {
3472            diagnostic: None,
3473            usage: None,
3474            command_id: format!("prompt-{accepted_ordinal}"),
3475            accepted_ordinal: Some(accepted_ordinal),
3476            turn_start_position: Some(accepted_ordinal + 1),
3477            completed_ordinal: accepted_ordinal + 2,
3478            completed_at_ms: 500,
3479            outcome: TurnOutcomeKind::Completed {
3480                stop_reason: stop_reason.into(),
3481            },
3482        }
3483    }
3484
3485    fn idle(outcome: Option<MaterializedTurnOutcome>) -> WaitObservation {
3486        WaitObservation {
3487            lifecycle: Some(ViewerLifecycleCategory::Live),
3488            execution: MaterializedExecutionState::Idle,
3489            last_turn_outcome: outcome,
3490            ..WaitObservation::default()
3491        }
3492    }
3493
3494    #[test]
3495    fn an_earlier_prompt_s_outcome_never_answers_a_later_prompt_s_wait() {
3496        let request = WaitRequest {
3497            return_on_input: false,
3498            turn_id: Some(12),
3499            timeout_secs: None,
3500        };
3501        // Prompt A was accepted at 10 and finished while B, accepted at 12, is
3502        // still queued. Idle plus "newest turn" would answer with A's ending.
3503        assert_eq!(
3504            resolve_wait(&idle(Some(completed(10, "end_turn"))), &request),
3505            None
3506        );
3507        let decision = resolve_wait(&idle(Some(completed(12, "end_turn"))), &request)
3508            .expect("B's own outcome ends the wait");
3509        assert_eq!(decision.outcome, WaitOutcome::Finished);
3510        assert_eq!(decision.turn_id, Some(12));
3511    }
3512
3513    #[test]
3514    fn a_capacity_outcome_only_ends_the_wait_once_no_retry_is_armed() {
3515        let request = WaitRequest {
3516            return_on_input: false,
3517            turn_id: Some(10),
3518            timeout_secs: None,
3519        };
3520        let mut pending = idle(Some(completed(10, "ModelCapacity")));
3521        pending.capacity_retry = Some(CapacityRetry {
3522            attempt: 1,
3523            retry_at_ms: 60_000,
3524            command_id: "capacity-retry-10".into(),
3525            submitted: false,
3526        });
3527        assert_eq!(
3528            resolve_wait(&pending, &request),
3529            None,
3530            "the worker will retry, so the caller must not prompt over it"
3531        );
3532
3533        let settled = idle(Some(completed(10, "ModelCapacity")));
3534        assert_eq!(
3535            resolve_wait(&settled, &request).unwrap().outcome,
3536            WaitOutcome::QuotaLimit
3537        );
3538    }
3539
3540    #[test]
3541    fn rejections_stopped_sessions_and_an_empty_session_each_end_the_wait() {
3542        let anything = WaitRequest::default();
3543
3544        let mut rejected = idle(None);
3545        rejected.last_turn_outcome = Some(MaterializedTurnOutcome {
3546            diagnostic: None,
3547            usage: None,
3548            command_id: "prompt-1".into(),
3549            accepted_ordinal: Some(4),
3550            turn_start_position: None,
3551            completed_ordinal: 5,
3552            completed_at_ms: 10,
3553            outcome: TurnOutcomeKind::Rejected {
3554                message: "transport failed".into(),
3555            },
3556        });
3557        let decision = resolve_wait(&rejected, &anything).unwrap();
3558        assert_eq!(decision.outcome, WaitOutcome::Error);
3559        assert_eq!(decision.message.as_deref(), Some("transport failed"));
3560
3561        let mut stopped = idle(Some(completed(10, "end_turn")));
3562        stopped.lifecycle = Some(ViewerLifecycleCategory::Stopped);
3563        assert_eq!(
3564            resolve_wait(&stopped, &anything).unwrap().outcome,
3565            WaitOutcome::Stopped,
3566            "a stopped session cannot finish a turn, whatever its last one did"
3567        );
3568
3569        let decision = resolve_wait(&idle(None), &anything).unwrap();
3570        assert_eq!(decision.outcome, WaitOutcome::Finished);
3571        assert_eq!(
3572            decision.turn_id, None,
3573            "an idle session with nothing queued has no turn to name"
3574        );
3575
3576        let mut running = idle(None);
3577        running.execution = MaterializedExecutionState::Running { started_at_ms: 1 };
3578        assert_eq!(resolve_wait(&running, &anything), None);
3579
3580        let mut queued = idle(Some(completed(10, "end_turn")));
3581        queued.queued = 1;
3582        assert_eq!(
3583            resolve_wait(&queued, &anything),
3584            None,
3585            "a queued prompt means the session is not done"
3586        );
3587    }
3588
3589    #[test]
3590    fn a_launch_failure_fails_the_wait_but_an_unrelated_session_error_does_not() {
3591        let mut launch_failed = idle(None);
3592        launch_failed.launch_failed = true;
3593        assert_eq!(
3594            resolve_wait(&launch_failed, &WaitRequest::default())
3595                .unwrap()
3596                .outcome,
3597            WaitOutcome::Error,
3598            "nothing will finish a turn on a session that never launched"
3599        );
3600
3601        let failed_start = WaitObservation {
3602            start_status: Some(StartStatus::Failed {
3603                message: "the profile has no home".into(),
3604            }),
3605            ..idle(None)
3606        };
3607        let decision = resolve_wait(&failed_start, &WaitRequest::default()).unwrap();
3608        assert_eq!(decision.outcome, WaitOutcome::Error);
3609        assert_eq!(decision.message.as_deref(), Some("the profile has no home"));
3610
3611        let durable_failure = WaitObservation {
3612            lifecycle: Some(ViewerLifecycleCategory::Failed),
3613            ..idle(None)
3614        };
3615        let decision = resolve_wait(&durable_failure, &WaitRequest::default()).unwrap();
3616        assert_eq!(decision.outcome, WaitOutcome::Error);
3617        assert_eq!(
3618            decision.message.as_deref(),
3619            Some("the session is in a failed state")
3620        );
3621
3622        // The session carries an error from some earlier action. The turn the
3623        // caller named is running fine, so the wait keeps waiting.
3624        let running = WaitObservation {
3625            execution: MaterializedExecutionState::Running { started_at_ms: 1 },
3626            active_turn: Some(MaterializedTurn {
3627                command_id: "prompt-12".into(),
3628                accepted_ordinal: Some(12),
3629                turn_start_position: 13,
3630                started_at_ms: 1,
3631            }),
3632            ..idle(Some(completed(10, "end_turn")))
3633        };
3634        assert_eq!(
3635            resolve_wait(
3636                &running,
3637                &WaitRequest {
3638                    return_on_input: false,
3639                    turn_id: Some(12),
3640                    timeout_secs: None,
3641                }
3642            ),
3643            None,
3644            "a stale session error must not report a running turn as failed"
3645        );
3646    }
3647
3648    #[test]
3649    fn a_launch_failure_for_another_session_is_not_this_session_s() {
3650        let (config, state) = sample_config_state();
3651        let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
3652        let session_id = snapshot.sessions[0].id.clone();
3653        snapshot.launch_failures = vec![super::super::ViewerLaunchFailure {
3654            id: format!("{}-4", std::process::id()),
3655            workspace_id: snapshot.sessions[0].workspace_id.clone(),
3656            session_id: Some("some-other-session".to_owned()),
3657        }];
3658
3659        let observation = build_observation(&snapshot, &snapshot.sessions[0], None, None, None);
3660        assert!(
3661            !observation.launch_failed,
3662            "another session's failed launch says nothing about this one"
3663        );
3664
3665        snapshot.launch_failures[0].session_id = Some(session_id);
3666        let observation = build_observation(&snapshot, &snapshot.sessions[0], None, None, None);
3667        assert!(observation.launch_failed);
3668    }
3669
3670    #[test]
3671    fn relay_health_names_each_way_the_live_view_can_be_unusable() {
3672        use mj_client::session::{ManagedSessionView, ViewError};
3673
3674        let connected = ManagedSessionView {
3675            connected: true,
3676            ..ManagedSessionView::default()
3677        };
3678        assert_eq!(
3679            RelayHealth::from(&connected),
3680            RelayHealth {
3681                state: RelayState::Connected,
3682                detail: None,
3683            }
3684        );
3685        assert_eq!(
3686            RelayHealth::from(&ManagedSessionView::default()).state,
3687            RelayState::Disconnected,
3688            "not yet attached is not the same as a failure"
3689        );
3690
3691        for (error, expected) in [
3692            (
3693                ViewError::Unreachable("ssh: connection refused".into()),
3694                RelayState::Unreachable,
3695            ),
3696            (
3697                ViewError::TargetMissing("container gone".into()),
3698                RelayState::TargetMissing,
3699            ),
3700            (
3701                ViewError::ProjectionIntegrity("digest mismatch".into()),
3702                RelayState::ProjectionIntegrity,
3703            ),
3704        ] {
3705            let detail = error.detail().to_owned();
3706            // Connected plus an error is what a relay that dropped mid-turn
3707            // looks like; the error is the thing the caller needs.
3708            let view = ManagedSessionView {
3709                connected: true,
3710                error: Some(error),
3711                ..ManagedSessionView::default()
3712            };
3713            assert_eq!(
3714                RelayHealth::from(&view),
3715                RelayHealth {
3716                    state: expected,
3717                    detail: Some(detail),
3718                }
3719            );
3720        }
3721    }
3722
3723    #[tokio::test]
3724    async fn the_diff_route_answers_a_patch_and_maps_export_failures() {
3725        let backend = Arc::new(FakeBackend {
3726            diff: Some("--- a/one\n+++ b/one\n".to_owned()),
3727            ..FakeBackend::default()
3728        });
3729        let (app, _actions, _snapshot_tx, _bundles) = api_app(backend, |_| {});
3730
3731        let response = app
3732            .clone()
3733            .oneshot(
3734                bearer(Request::get("/api/v1/sessions/session-1/diff"))
3735                    .body(Body::empty())
3736                    .unwrap(),
3737            )
3738            .await
3739            .unwrap();
3740        assert_eq!(response.status(), StatusCode::OK);
3741        assert_eq!(
3742            response.headers().get(CONTENT_TYPE).unwrap(),
3743            "text/x-diff; charset=utf-8"
3744        );
3745        let body = response.into_body().collect().await.unwrap().to_bytes();
3746        assert!(String::from_utf8_lossy(&body).contains("+++ b/one"));
3747
3748        // A refusal is something the caller can act on; a failure is not.
3749        let (app, _actions, _snapshot_tx, _bundles) =
3750            api_app(Arc::new(FakeBackend::default()), |_| {});
3751        let response = app
3752            .oneshot(
3753                bearer(Request::get("/api/v1/sessions/session-1/diff"))
3754                    .body(Body::empty())
3755                    .unwrap(),
3756            )
3757            .await
3758            .unwrap();
3759        assert_eq!(response.status(), StatusCode::CONFLICT);
3760
3761        let (app, _actions, _snapshot_tx, _bundles) = api_app(
3762            Arc::new(FakeBackend {
3763                diff_fails: true,
3764                ..FakeBackend::default()
3765            }),
3766            |_| {},
3767        );
3768        let response = app
3769            .oneshot(
3770                bearer(Request::get("/api/v1/sessions/session-1/diff"))
3771                    .body(Body::empty())
3772                    .unwrap(),
3773            )
3774            .await
3775            .unwrap();
3776        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
3777        assert_eq!(json_body(response).await["error"], "git exploded");
3778    }
3779
3780    #[tokio::test]
3781    async fn the_file_route_returns_bytes_and_refuses_a_path_that_leaves_the_workspace() {
3782        let backend = Arc::new(FakeBackend {
3783            file: Some(b"file bytes".to_vec()),
3784            ..FakeBackend::default()
3785        });
3786        let (app, _actions, _snapshot_tx, _bundles) = api_app(backend.clone(), |_| {});
3787
3788        let response = app
3789            .clone()
3790            .oneshot(
3791                bearer(Request::get(
3792                    "/api/v1/sessions/session-1/files?path=app/README.md",
3793                ))
3794                .body(Body::empty())
3795                .unwrap(),
3796            )
3797            .await
3798            .unwrap();
3799        assert_eq!(response.status(), StatusCode::OK);
3800        assert_eq!(
3801            response.headers().get(CONTENT_TYPE).unwrap(),
3802            "application/octet-stream"
3803        );
3804        let body = response.into_body().collect().await.unwrap().to_bytes();
3805        assert_eq!(body.as_ref(), b"file bytes");
3806        assert_eq!(
3807            backend.file_paths.lock().unwrap().as_slice(),
3808            [PathBuf::from("app/README.md")]
3809        );
3810
3811        for path in ["../etc/passwd", "/etc/passwd"] {
3812            let response = app
3813                .clone()
3814                .oneshot(
3815                    bearer(Request::get(format!(
3816                        "/api/v1/sessions/session-1/files?path={path}"
3817                    )))
3818                    .body(Body::empty())
3819                    .unwrap(),
3820                )
3821                .await
3822                .unwrap();
3823            assert_eq!(
3824                response.status(),
3825                StatusCode::BAD_REQUEST,
3826                "{path} must never reach the target"
3827            );
3828        }
3829        assert_eq!(
3830            backend.file_paths.lock().unwrap().len(),
3831            1,
3832            "a rejected path is not sent to the backend"
3833        );
3834    }
3835
3836    #[tokio::test]
3837    async fn the_export_route_serves_each_kind_in_its_own_form() {
3838        let backend = Arc::new(FakeBackend {
3839            diff: Some("--- a/one\n".to_owned()),
3840            pushed: Some(PushedBranch {
3841                branch: String::new(),
3842                remote: "origin".to_owned(),
3843            }),
3844            bundle: Some(BundleExport {
3845                repository: "app".to_owned(),
3846                bytes: b"bundle bytes".to_vec(),
3847            }),
3848            ..FakeBackend::default()
3849        });
3850        let (app, _actions, _snapshot_tx, _bundles) = api_app(backend, |_| {});
3851
3852        let response = app
3853            .clone()
3854            .oneshot(
3855                bearer(Request::post("/api/v1/sessions/session-1/export"))
3856                    .header(CONTENT_TYPE, "application/json")
3857                    .body(Body::from(r#"{"kind":"patch"}"#))
3858                    .unwrap(),
3859            )
3860            .await
3861            .unwrap();
3862        assert_eq!(
3863            response.headers().get(CONTENT_TYPE).unwrap(),
3864            "text/x-diff; charset=utf-8"
3865        );
3866
3867        let response = app
3868            .clone()
3869            .oneshot(
3870                bearer(Request::post("/api/v1/sessions/session-1/export"))
3871                    .header(CONTENT_TYPE, "application/json")
3872                    .body(Body::from(r#"{"kind":"branch","branch":"review/one"}"#))
3873                    .unwrap(),
3874            )
3875            .await
3876            .unwrap();
3877        assert_eq!(response.status(), StatusCode::OK);
3878        let body = json_body(response).await;
3879        assert_eq!(body["branch"], "review/one");
3880        assert_eq!(body["remote"], "origin");
3881
3882        let response = app
3883            .clone()
3884            .oneshot(
3885                bearer(Request::post("/api/v1/sessions/session-1/export"))
3886                    .header(CONTENT_TYPE, "application/json")
3887                    .body(Body::from(r#"{"kind":"branch"}"#))
3888                    .unwrap(),
3889            )
3890            .await
3891            .unwrap();
3892        assert_eq!(
3893            response.status(),
3894            StatusCode::BAD_REQUEST,
3895            "a branch export without a branch name is the caller's mistake"
3896        );
3897
3898        let response = app
3899            .oneshot(
3900                bearer(Request::post("/api/v1/sessions/session-1/export"))
3901                    .header(CONTENT_TYPE, "application/json")
3902                    .body(Body::from(r#"{"kind":"bundle"}"#))
3903                    .unwrap(),
3904            )
3905            .await
3906            .unwrap();
3907        assert_eq!(
3908            response.headers().get(CONTENT_TYPE).unwrap(),
3909            "application/octet-stream"
3910        );
3911        assert_eq!(
3912            response.headers().get(CONTENT_DISPOSITION).unwrap(),
3913            "attachment; filename=\"session-1-app.bundle\""
3914        );
3915        let body = response.into_body().collect().await.unwrap().to_bytes();
3916        assert_eq!(body.as_ref(), b"bundle bytes");
3917    }
3918
3919    #[tokio::test]
3920    async fn an_empty_bundle_is_refused_rather_than_served_as_an_empty_file() {
3921        let (app, _actions, _snapshot_tx, _bundles) =
3922            api_app(Arc::new(FakeBackend::default()), |_| {});
3923        let response = app
3924            .oneshot(
3925                bearer(Request::post("/api/v1/sessions/session-1/export"))
3926                    .header(CONTENT_TYPE, "application/json")
3927                    .body(Body::from(r#"{"kind":"bundle"}"#))
3928                    .unwrap(),
3929            )
3930            .await
3931            .unwrap();
3932        assert_eq!(response.status(), StatusCode::CONFLICT);
3933        assert_eq!(
3934            json_body(response).await["error"],
3935            "no commits beyond the session base"
3936        );
3937    }
3938    fn input_request() -> mj_core::elicitation::ElicitationRequest {
3939        mj_core::elicitation::ElicitationRequest::from_acp_params("question-1", serde_json::json!({
3940            "sessionId": "session-1", "mode": "form", "message": "Choose a name", "requestedSchema": {
3941                "type": "object", "required": ["name"], "properties": {"name": {"type": "string"}}
3942            }
3943        })).unwrap()
3944    }
3945
3946    #[tokio::test]
3947    async fn file_upload_accepts_large_binary_bodies_and_rejects_unsafe_paths_and_limits() {
3948        let backend = Arc::new(FakeBackend::default());
3949        let (app, _actions, snapshots, _bundles) = api_app(backend.clone(), |snapshot| {
3950            snapshot.sessions[0].is_idle = true;
3951            snapshot.sessions[0].lifecycle = ViewerLifecycleCategory::Live;
3952        });
3953        let payload: Vec<u8> = (0..3 * 1024 * 1024).map(|i| (i % 251) as u8).collect();
3954        let response = app
3955            .clone()
3956            .oneshot(
3957                bearer(Request::put(
3958                    "/api/v1/sessions/session-1/files?path=input/data.bin&overwrite=true",
3959                ))
3960                .body(Body::from(payload.clone()))
3961                .unwrap(),
3962            )
3963            .await
3964            .unwrap();
3965        assert_eq!(response.status(), StatusCode::OK);
3966        assert_eq!(json_body(response).await["bytes"], payload.len());
3967        assert_eq!(
3968            backend.file_writes.lock().unwrap()[0],
3969            (PathBuf::from("input/data.bin"), payload, true)
3970        );
3971        for path in ["../outside", "/absolute", "nested/../../outside"] {
3972            let response = app
3973                .clone()
3974                .oneshot(
3975                    bearer(Request::put(format!(
3976                        "/api/v1/sessions/session-1/files?path={path}"
3977                    )))
3978                    .body(Body::from("bad"))
3979                    .unwrap(),
3980                )
3981                .await
3982                .unwrap();
3983            assert_eq!(response.status(), StatusCode::BAD_REQUEST);
3984        }
3985        let response = app
3986            .clone()
3987            .oneshot(
3988                bearer(Request::put("/api/v1/sessions/session-1/files?path=large"))
3989                    .body(Body::from(vec![
3990                        0;
3991                        mj_checkpoint::archive::MAX_SESSION_FILE_BYTES
3992                            as usize
3993                            + 1
3994                    ]))
3995                    .unwrap(),
3996            )
3997            .await
3998            .unwrap();
3999        assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
4000        snapshots.send_modify(|s| s.sessions[0].is_idle = false);
4001        let response = app
4002            .oneshot(
4003                bearer(Request::put("/api/v1/sessions/session-1/files?path=busy"))
4004                    .body(Body::from("bad"))
4005                    .unwrap(),
4006            )
4007            .await
4008            .unwrap();
4009        assert_eq!(response.status(), StatusCode::CONFLICT);
4010        assert_eq!(backend.file_writes.lock().unwrap().len(), 1);
4011    }
4012
4013    #[tokio::test]
4014    async fn structured_inputs_are_listed_validated_and_forwarded() {
4015        let (app, mut actions, _snapshots, _bundles) =
4016            api_app(Arc::new(FakeBackend::default()), |s| {
4017                s.sessions[0].pending_elicitations = vec![input_request()]
4018            });
4019        let response = app
4020            .clone()
4021            .oneshot(
4022                bearer(Request::get("/api/v1/sessions/session-1/elicitations"))
4023                    .body(Body::empty())
4024                    .unwrap(),
4025            )
4026            .await
4027            .unwrap();
4028        assert_eq!(json_body(response).await[0]["id"], "question-1");
4029        let response = app
4030            .clone()
4031            .oneshot(
4032                bearer(Request::post(
4033                    "/api/v1/sessions/session-1/elicitations/question-1",
4034                ))
4035                .header(CONTENT_TYPE, "application/json")
4036                .body(Body::from(r#"{"action":"accept","content":{}}"#))
4037                .unwrap(),
4038            )
4039            .await
4040            .unwrap();
4041        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4042        assert!(actions.try_recv().is_err());
4043        let response = tokio::spawn(
4044            app.oneshot(
4045                bearer(Request::post(
4046                    "/api/v1/sessions/session-1/elicitations/question-1",
4047                ))
4048                .header(CONTENT_TYPE, "application/json")
4049                .body(Body::from(
4050                    r#"{"action":"accept","content":{"name":"example"}}"#,
4051                ))
4052                .unwrap(),
4053            ),
4054        );
4055        let action = actions.recv().await.unwrap();
4056        assert!(
4057            matches!(action.action, ControllerAction::RespondElicitation { elicitation_id, .. } if elicitation_id == "question-1")
4058        );
4059        action
4060            .reply
4061            .send(ActionOutcome::Accepted { session_id: None })
4062            .unwrap();
4063        assert_eq!(
4064            response.await.unwrap().unwrap().status(),
4065            StatusCode::ACCEPTED
4066        );
4067    }
4068
4069    #[test]
4070    fn input_aware_wait_is_opt_in_and_respects_completed_turns_and_stopping() {
4071        let mut observation = WaitObservation {
4072            pending_elicitations: vec![input_request()],
4073            execution: MaterializedExecutionState::Running { started_at_ms: 1 },
4074            ..Default::default()
4075        };
4076        assert!(resolve_wait(&observation, &WaitRequest::default()).is_none());
4077        let mut request = WaitRequest {
4078            return_on_input: true,
4079            ..Default::default()
4080        };
4081        assert_eq!(
4082            resolve_wait(&observation, &request).unwrap().outcome,
4083            WaitOutcome::InputRequired
4084        );
4085        observation.last_turn_outcome = Some(completed(5, "end_turn"));
4086        request.turn_id = Some(5);
4087        assert_eq!(
4088            resolve_wait(&observation, &request).unwrap().outcome,
4089            WaitOutcome::Finished
4090        );
4091        observation.lifecycle = Some(ViewerLifecycleCategory::Stopping);
4092        assert_eq!(
4093            resolve_wait(&observation, &request).unwrap().outcome,
4094            WaitOutcome::Stopped
4095        );
4096    }
4097
4098    #[tokio::test]
4099    async fn input_aware_wait_returns_the_form_without_needing_a_turn_summary() {
4100        let (app, _actions, _snapshots, _bundles) =
4101            api_app(Arc::new(FakeBackend::default()), |s| {
4102                s.sessions[0].pending_elicitations = vec![input_request()]
4103            });
4104        let response = app
4105            .oneshot(
4106                bearer(Request::post("/api/v1/sessions/session-1/wait"))
4107                    .header(CONTENT_TYPE, "application/json")
4108                    .body(Body::from(r#"{"return_on_input":true}"#))
4109                    .unwrap(),
4110            )
4111            .await
4112            .unwrap();
4113        assert_eq!(response.status(), StatusCode::OK);
4114        let body = json_body(response).await;
4115        assert_eq!(body["outcome"], "input_required");
4116        assert_eq!(body["pending_elicitations"][0]["id"], "question-1");
4117    }
4118}