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