Skip to main content

recursive/http/
handlers.rs

1//! HTTP handler functions for the agent API.
2
3use async_trait::async_trait;
4use axum::{
5    extract::{Path, State},
6    http::StatusCode,
7    response::sse::{Event, Sse},
8    Json,
9};
10use std::collections::HashMap;
11use std::convert::Infallible;
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::sync::Arc;
14use std::time::{Duration, SystemTime};
15use tokio::sync::broadcast;
16use tokio_stream::{wrappers::BroadcastStream, wrappers::IntervalStream, StreamExt};
17
18use crate::event::{AgentEvent, ChannelSink, NullSink};
19use crate::message::Role;
20use crate::permissions::{LayeredPermissionsConfig, PermissionMode};
21use crate::runtime::AgentRuntimeBuilder;
22
23use super::{
24    build_openapi_spec, ApiError, AppState, CreateSessionRequest, CreateSessionResponse,
25    ErrorResponse, ListSessionsQuery, RunRequest, RunResponse, SessionDetailResponse, SessionInfo,
26    SessionMessageRequest, SessionMessageResponse, SessionState, SetGoalRequest, SlashCommandInfo,
27    SseContentBlock, SseEvent, ToolInfo, UsageInfo,
28};
29
30// Constant body — no branching worth scoring.
31#[cfg_attr(test, mutants::skip)]
32pub(super) async fn health() -> &'static str {
33    "ok"
34}
35
36/// Update metrics after a successful agent run.
37fn record_run_success(metrics: &super::Metrics, steps: usize, usage: &crate::llm::TokenUsage) {
38    metrics.agent_runs_total.fetch_add(1, Ordering::Relaxed);
39    metrics.agent_runs_success.fetch_add(1, Ordering::Relaxed);
40    metrics
41        .agent_steps_total
42        .fetch_add(steps as u64, Ordering::Relaxed);
43    metrics
44        .tokens_prompt_total
45        .fetch_add(usage.prompt_tokens as u64, Ordering::Relaxed);
46    metrics
47        .tokens_completion_total
48        .fetch_add(usage.completion_tokens as u64, Ordering::Relaxed);
49}
50
51/// Update metrics after a failed agent run.
52fn record_run_failed(metrics: &super::Metrics) {
53    metrics.agent_runs_total.fetch_add(1, Ordering::Relaxed);
54    metrics.agent_runs_failed.fetch_add(1, Ordering::Relaxed);
55}
56
57/// Map a typed runtime [`crate::error::Error`] to the correct HTTP status code.
58///
59/// Goal 361: previously every run failure collapsed to 500, so a client could
60/// not distinguish "your input was bad" (400) from "you got rate limited"
61/// (429, retry after N seconds) from "we crashed" (500). This helper is the
62/// single place that translates a runtime error into the right [`ApiError`];
63/// both run-entry handlers call it after recording metrics.
64///
65/// Deliberately NOT mapped per-variant: `Io`/`Json`/`Mcp`/`Llm`/`Timeout`/
66/// `Tool` etc. are server-side failures — 500 is correct for them. Only
67/// client-correctable or retryable conditions get a 4xx/503.
68///
69/// `Cancelled` maps to 503 (Service Unavailable) rather than 500: 499 is a
70/// non-standard nginx code and axum/tower has no built-in constant for it,
71/// and this goal explicitly prefers 503 over 500 (a client retrying a cancel
72/// as 500 is the pre-existing bug; 503 at least signals "transient, ok to
73/// retry"). We do NOT return 200-with-body for a cancel because no other
74/// finish-reason in this API uses that convention — the run genuinely did
75/// not complete.
76fn map_run_error(e: &crate::error::Error) -> ApiError {
77    use crate::error::Error;
78    match e {
79        Error::Cancelled => ApiError::new(StatusCode::SERVICE_UNAVAILABLE, "agent run cancelled"),
80        Error::PermissionDenied { .. } | Error::PermissionDeniedLimit { .. } => {
81            ApiError::forbidden(e.to_string())
82        }
83        Error::RateLimited { retry_after_ms, .. } => {
84            // `with_retry_after` takes whole seconds; floor so a sub-second
85            // wait becomes `Retry-After: 0` (retry immediately) rather than
86            // over-promising.
87            ApiError::new(StatusCode::TOO_MANY_REQUESTS, e.to_string())
88                .with_retry_after((*retry_after_ms / 1000) as u32)
89        }
90        Error::BadToolArgs { .. } => ApiError::bad_request(e.to_string()),
91        // Everything else is genuinely internal: keep 500.
92        _ => ApiError::internal(e.to_string()),
93    }
94}
95
96// Thin wrapper around `build_openapi_spec` (schema covered elsewhere).
97#[cfg_attr(test, mutants::skip)]
98pub(super) async fn openapi_spec() -> Json<serde_json::Value> {
99    Json(build_openapi_spec())
100}
101
102// Pure clone of AppState field — no branching worth scoring.
103#[cfg_attr(test, mutants::skip)]
104pub(super) async fn list_tools(State(state): State<Arc<AppState>>) -> Json<Vec<ToolInfo>> {
105    Json(state.tools.clone())
106}
107
108pub(super) async fn run_agent(
109    State(state): State<Arc<AppState>>,
110    Json(body): Json<RunRequest>,
111) -> Result<Json<RunResponse>, ApiError> {
112    // Validate: goal must not be empty
113    if body.goal.trim().is_empty() {
114        return Err(ApiError::bad_request("missing or empty 'goal' field"));
115    }
116
117    // Acquire a semaphore permit to limit concurrent runs.
118    let _permit = state
119        .run_semaphore
120        .clone()
121        .acquire_owned()
122        .await
123        .map_err(|_| {
124            ApiError::new(
125                StatusCode::SERVICE_UNAVAILABLE,
126                "too many concurrent runs, try again later",
127            )
128        })?;
129    let max_steps = body.max_steps.unwrap_or(state.config.max_steps as u32) as usize;
130    let system_prompt = match body.system_prompt {
131        Some(s) => s,
132        None => {
133            let mut p = state.config.system_prompt.clone();
134            if let Some(extra) = &body.append_system_prompt {
135                p.push('\n');
136                p.push_str(extra);
137            }
138            p
139        }
140    };
141    // Common system-prompt assembly: project context (AGENTS.md + CLAUDE.md)
142    // + base + skill index + coordinator/sub_agent note (when enabled).
143    let assembled_system_prompt = crate::assemble_system_prompt(
144        &system_prompt,
145        &state.config.workspace,
146        &state.skills,
147        state.config.subagent_enabled,
148    );
149    let system_prompt = assembled_system_prompt.full;
150    let prompt_segments = assembled_system_prompt.segments;
151    let mut tool_registry = state.tool_registry.clone();
152    if let Some(mode_str) = body.permission_mode.as_deref() {
153        let perm_mode = parse_permission_mode(mode_str, state.config.allow_bypass_permissions);
154        tool_registry = tool_registry.with_permissions(LayeredPermissionsConfig {
155            mode: perm_mode,
156            layers: Vec::new(),
157        });
158    }
159
160    let mut runtime = AgentRuntimeBuilder::new()
161        .llm(state.provider.clone())
162        .tools(tool_registry)
163        .system_prompt(system_prompt)
164        .prompt_segments(prompt_segments)
165        .max_steps(max_steps)
166        .build()
167        .map_err(|e| ApiError::internal(format!("failed to build runtime: {e}")))?;
168
169    let outcome = runtime.run(&body.goal).await.map_err(|e| {
170        record_run_failed(&state.metrics);
171        map_run_error(&e)
172    })?;
173
174    record_run_success(&state.metrics, outcome.steps, &outcome.total_usage);
175
176    // Serialize transcript messages to JSON values
177    let messages: Vec<serde_json::Value> = runtime
178        .transcript()
179        .iter()
180        .filter_map(|msg| serde_json::to_value(msg).ok())
181        .collect();
182
183    let finish_reason = outcome.finish_reason.to_string();
184
185    Ok(Json(RunResponse {
186        status: "success".into(),
187        finish_reason,
188        messages,
189        usage: UsageInfo {
190            total_steps: outcome.steps as u32,
191            total_tokens: outcome.total_usage.total_tokens as u64,
192        },
193    }))
194}
195
196// ── Request parsing helpers ────────────────────────────────────────────────
197
198/// Parse `permission_mode` string from an API request body.
199///
200/// Accepted values (case-insensitive): `"default"`, `"auto"`, `"strict"`,
201/// `"bypass"` / `"bypass_permissions"`. Unknown values fall back to `Default`.
202fn parse_permission_mode(s: &str, allow_bypass: bool) -> PermissionMode {
203    match s.to_ascii_lowercase().as_str() {
204        "auto" => PermissionMode::Auto,
205        "strict" => PermissionMode::Strict,
206        "bypass" | "bypass_permissions" if allow_bypass => PermissionMode::BypassPermissions,
207        _ => PermissionMode::Default,
208    }
209}
210
211// ── Session endpoints ──────────────────────────────────────────────────────
212
213/// Generate a session ID using UUID v7 (time-ordered, globally unique).
214// Non-deterministic UUID — not unit-observable for mutation scoring.
215#[cfg_attr(test, mutants::skip)]
216fn generate_session_id() -> String {
217    uuid::Uuid::now_v7().to_string()
218}
219
220/// Format a SystemTime as a basic ISO-8601 string (without chrono).
221fn format_timestamp(t: SystemTime) -> String {
222    let dur = t.duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default();
223    let secs = dur.as_secs();
224    // Basic formatting: seconds since epoch as a simple numeric timestamp
225    // For a more human-readable format we do manual UTC conversion
226    let days = secs / 86400;
227    let remaining = secs % 86400;
228    let hours = remaining / 3600;
229    let minutes = (remaining % 3600) / 60;
230    let seconds = remaining % 60;
231
232    // Days since 1970-01-01 — delegate to the O(1) civil-calendar impl in session.rs
233    let (year, month, day) = crate::session::epoch_day_to_ymd(days as i64);
234    format!(
235        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
236        year, month, day, hours, minutes, seconds
237    )
238}
239
240/// POST /sessions — create a new session.
241pub(super) async fn create_session(
242    State(state): State<Arc<AppState>>,
243    Json(body): Json<CreateSessionRequest>,
244) -> Result<(StatusCode, Json<CreateSessionResponse>), ApiError> {
245    let id = generate_session_id();
246    let created_at = format_timestamp(SystemTime::now());
247    let system_prompt = match body.system_prompt {
248        Some(s) => s,
249        None => {
250            let mut p = state.config.system_prompt.clone();
251            if let Some(extra) = &body.append_system_prompt {
252                p.push('\n');
253                p.push_str(extra);
254            }
255            p
256        }
257    };
258    // Common system-prompt assembly: project context (AGENTS.md + CLAUDE.md)
259    // + base + skill index + coordinator/sub_agent note (when enabled).
260    let assembled_system_prompt = crate::assemble_system_prompt(
261        &system_prompt,
262        &state.config.workspace,
263        &state.skills,
264        state.config.subagent_enabled,
265    );
266    let system_prompt = assembled_system_prompt.full;
267    let prompt_segments = assembled_system_prompt.segments;
268    let max_steps = body
269        .max_steps
270        .map(|n| n as usize)
271        .unwrap_or(state.config.max_steps);
272    let mut tool_registry = state.tool_registry.clone();
273    if let Some(mode_str) = body.permission_mode.as_deref() {
274        let perm_mode = parse_permission_mode(mode_str, state.config.allow_bypass_permissions);
275        tool_registry = tool_registry.with_permissions(LayeredPermissionsConfig {
276            mode: perm_mode,
277            layers: Vec::new(),
278        });
279    }
280
281    let mut runtime = AgentRuntimeBuilder::new()
282        .llm(state.provider.clone())
283        .tools(tool_registry)
284        .system_prompt(system_prompt)
285        .prompt_segments(prompt_segments)
286        .max_steps(max_steps)
287        .build()
288        .map_err(|e| ApiError::internal(format!("failed to build session runtime: {e}")))?;
289
290    // Register the session ID so all turns emit tracing spans with session_id
291    // and transcript is auto-saved to the storage backend after each turn.
292    runtime.set_session_id(&id);
293
294    // Extract the gate before moving runtime into the Mutex so HTTP handlers
295    // can approve/reject without acquiring the per-session runtime lock.
296    let plan_approval_gate = runtime.plan_approval_gate();
297
298    let session = SessionState {
299        id: id.clone(),
300        created_at: created_at.clone(),
301        title: body.session_name,
302        runtime: Arc::new(tokio::sync::Mutex::new(runtime)),
303        plan_approval_gate,
304        interrupt_token: Arc::new(tokio::sync::Mutex::new(None)),
305        non_system_message_count: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
306        last_active_ms: Arc::new(AtomicU64::new(super::now_session_ms())),
307        prompt_tokens: Arc::new(AtomicU64::new(0)),
308        completion_tokens: Arc::new(AtomicU64::new(0)),
309    };
310
311    state.sessions.write().await.insert(id.clone(), session);
312    state
313        .metrics
314        .sessions_active
315        .fetch_add(1, Ordering::Relaxed);
316    tracing::info!(session_id = %id, "session created");
317    Ok((
318        StatusCode::CREATED,
319        Json(CreateSessionResponse { id, created_at }),
320    ))
321}
322
323/// Response envelope for `GET /sessions`.
324///
325/// Wraps the paginated list of [`SessionInfo`] with a `total` count
326/// representing the **un-paginated** number of sessions known to the
327/// server. Clients use `total` to render "page X of Y" / scrollbars
328/// without having to fetch every page just to count sessions.
329#[derive(serde::Serialize)]
330pub(super) struct SessionList {
331    pub total: usize,
332    pub sessions: Vec<SessionInfo>,
333}
334
335/// GET /sessions — list all sessions, with optional `limit` and `offset` pagination.
336///
337/// Example: `GET /sessions?limit=10&offset=20`
338///
339/// Returns a [`SessionList`] envelope (`{ "total": N, "sessions": [...] }`)
340/// so paginated UIs can render total counts without fetching every page.
341pub(super) async fn list_sessions(
342    State(state): State<Arc<AppState>>,
343    axum::extract::Query(params): axum::extract::Query<ListSessionsQuery>,
344) -> Json<SessionList> {
345    let sessions = state.sessions.read().await;
346    let mut infos = Vec::with_capacity(sessions.len());
347    for s in sessions.values() {
348        // Read the pre-computed count without acquiring the runtime lock.
349        // The count is updated atomically whenever a non-system message is
350        // appended, so it remains accurate while a turn is in progress.
351        let message_count = s
352            .non_system_message_count
353            .load(std::sync::atomic::Ordering::Relaxed);
354        infos.push(SessionInfo {
355            id: s.id.clone(),
356            created_at: s.created_at.clone(),
357            message_count,
358            title: s.title.clone(),
359        });
360    }
361    // Sort by creation time (ISO 8601 lexicographic = chronological) so clients
362    // receive sessions in a predictable, meaningful order. Use `id` as a secondary
363    // key to break ties between sessions created in the same second.
364    infos.sort_by(|a, b| a.created_at.cmp(&b.created_at).then(a.id.cmp(&b.id)));
365    // `total` is the count BEFORE pagination so clients can compute total pages.
366    let total = infos.len();
367    // Apply offset + limit pagination.
368    let offset = params.offset.unwrap_or(0);
369    let page: Vec<SessionInfo> = infos
370        .into_iter()
371        .skip(offset)
372        .take(params.limit.unwrap_or(usize::MAX))
373        .collect();
374    Json(SessionList {
375        total,
376        sessions: page,
377    })
378}
379
380/// GET /sessions/:id — get session detail with messages.
381///
382/// Reads plan-approval status directly from the session gate (no runtime lock
383/// needed) so this endpoint stays responsive even while an agent turn is
384/// blocked awaiting plan approval.  Messages and todos fall back to empty
385/// vectors when the runtime is busy rather than deadlocking.
386pub(super) async fn get_session(
387    State(state): State<Arc<AppState>>,
388    Path(id): Path<String>,
389) -> Result<Json<SessionDetailResponse>, ApiError> {
390    let sessions = state.sessions.read().await;
391    let session = sessions
392        .get(&id)
393        .ok_or_else(|| ApiError::not_found("session not found"))?;
394
395    // Read plan status without locking the runtime Mutex so callers can poll
396    // while the agent is suspended inside `exit_plan_mode`.
397    let pending_plan = session
398        .plan_approval_gate
399        .pending_plan
400        .read()
401        .ok()
402        .and_then(|g| g.clone());
403    let status = if pending_plan.is_some() {
404        "plan_pending_approval".to_string()
405    } else {
406        "idle".to_string()
407    };
408
409    // Try a non-blocking lock for messages/todos/goal; fall back to empty when busy.
410    let (messages, todos, goal) = match session.runtime.try_lock() {
411        Ok(runtime) => {
412            let msgs = runtime
413                .transcript()
414                .iter()
415                .filter_map(|msg| serde_json::to_value(msg).ok())
416                .collect();
417            let todos = runtime.current_todos();
418            let goal = runtime.current_goal();
419            (msgs, todos, goal)
420        }
421        Err(_) => (vec![], vec![], None),
422    };
423
424    // Extract first/last user prompt for display without a separate lock.
425    let (first_prompt, last_prompt) = {
426        let user_msgs: Vec<String> = messages
427            .iter()
428            .filter_map(|m| {
429                if m.get("role")?.as_str()? == "user" {
430                    m.get("content")?.as_str().map(|s| s.to_string())
431                } else {
432                    None
433                }
434            })
435            .collect();
436        let first = user_msgs.first().cloned();
437        let last = user_msgs.last().cloned();
438        (first, last)
439    };
440
441    // Read token usage directly from atomic counters — no lock needed.
442    let prompt_tokens = session.prompt_tokens.load(Ordering::Relaxed);
443    let completion_tokens = session.completion_tokens.load(Ordering::Relaxed);
444
445    Ok(Json(SessionDetailResponse {
446        id: session.id.clone(),
447        created_at: session.created_at.clone(),
448        title: session.title.clone(),
449        messages,
450        todos,
451        status,
452        pending_plan,
453        goal,
454        first_prompt,
455        last_prompt,
456        prompt_tokens,
457        completion_tokens,
458    }))
459}
460
461/// DELETE /sessions/:id — remove a session.
462pub(super) async fn delete_session(
463    State(state): State<Arc<AppState>>,
464    Path(id): Path<String>,
465) -> Result<StatusCode, ApiError> {
466    // Look up the runtime under a read lock so we can take the per-session
467    // runtime Mutex and call `close()` without holding the global write
468    // lock across an await point.
469    let session_runtime = {
470        let sessions = state.sessions.read().await;
471        sessions.get(&id).map(|s| s.runtime.clone())
472    };
473    if let Some(runtime) = session_runtime {
474        // Fire SessionEnd (no outcome — the client is deleting the session
475        // without a terminating turn) and flip `session_closed` before the
476        // runtime is dropped. Idempotent on repeated calls.
477        let mut rt = runtime.lock().await;
478        rt.close(None).await;
479        drop(rt);
480        state.sessions.write().await.remove(&id);
481        state
482            .metrics
483            .sessions_active
484            .fetch_sub(1, Ordering::Relaxed);
485        // Clean up SSE event channel for this session.
486        state.event_channels.write().await.remove(&id);
487        tracing::info!(session_id = %id, "session deleted");
488        Ok(StatusCode::NO_CONTENT)
489    } else {
490        Err(ApiError::not_found("session not found"))
491    }
492}
493
494// ── Session patch endpoint (rename) ──────────────────────────────────────
495
496/// Request body for `PATCH /sessions/:id` — update mutable session fields.
497#[derive(serde::Deserialize, Debug)]
498pub(super) struct PatchSessionRequest {
499    /// Optional new title for the session.
500    title: Option<String>,
501}
502
503/// PATCH /sessions/:id — update mutable session metadata.
504///
505/// Currently supports setting/clearing the `title` field.
506///
507/// Example:
508/// ```text
509/// PATCH /sessions/abc123
510/// {"title": "Fix login bug"}
511/// ```
512pub(super) async fn patch_session(
513    State(state): State<Arc<AppState>>,
514    Path(id): Path<String>,
515    Json(body): Json<PatchSessionRequest>,
516) -> Result<Json<SessionInfo>, ApiError> {
517    let mut sessions = state.sessions.write().await;
518    let session = sessions
519        .get_mut(&id)
520        .ok_or_else(|| ApiError::not_found("session not found"))?;
521
522    if let Some(title) = body.title {
523        session.title = if title.is_empty() { None } else { Some(title) };
524    }
525
526    // Read the pre-computed non-system message count directly from the
527    // atomic. It is updated whenever a non-system message is appended, so
528    // we don't need to acquire the runtime lock here.
529    Ok(Json(SessionInfo {
530        id: session.id.clone(),
531        created_at: session.created_at.clone(),
532        message_count: session.non_system_message_count.load(Ordering::Relaxed),
533        title: session.title.clone(),
534    }))
535}
536
537// ── Fork session ─────────────────────────────────────────────────────────
538
539/// Response for `POST /sessions/:id/fork`.
540#[derive(serde::Serialize)]
541pub(super) struct ForkSessionResponse {
542    /// ID of the newly created forked session.
543    id: String,
544    /// Timestamp when the fork was created.
545    created_at: String,
546    /// Number of non-system messages copied from the source session
547    /// (matches the semantics of `SessionInfo.message_count` from
548    /// `GET /sessions`).
549    message_count: usize,
550}
551
552/// POST /sessions/:id/fork — fork a session, copying its transcript.
553///
554/// Creates a new session with the same transcript as the source session.
555/// The forked session is independent: subsequent messages do not affect the
556/// original.
557///
558/// Returns the new session's ID and metadata.
559pub(super) async fn fork_session(
560    State(state): State<Arc<AppState>>,
561    Path(id): Path<String>,
562) -> Result<(StatusCode, Json<ForkSessionResponse>), ApiError> {
563    // Snapshot the source transcript while holding the write lock.
564    let transcript_snapshot = {
565        let sessions = state.sessions.read().await;
566        let src = sessions
567            .get(&id)
568            .ok_or_else(|| ApiError::not_found("session not found"))?;
569        let rt = src
570            .runtime
571            .try_lock()
572            .map_err(|_| ApiError::conflict("session is busy"))?;
573        rt.transcript().to_vec()
574    };
575
576    // Build a new session with the copied transcript.
577    let new_id = generate_session_id();
578    let created_at = format_timestamp(SystemTime::now());
579    let base_system_prompt = state.config.system_prompt.clone();
580    // Common system-prompt assembly so the forked session matches every
581    // other channel (project context + skill index + sub-agent note).
582    let assembled_system_prompt = crate::assemble_system_prompt(
583        &base_system_prompt,
584        &state.config.workspace,
585        &state.skills,
586        state.config.subagent_enabled,
587    );
588    let system_prompt = assembled_system_prompt.full;
589    let prompt_segments = assembled_system_prompt.segments;
590
591    let mut runtime = AgentRuntimeBuilder::new()
592        .llm(state.provider.clone())
593        .tools(state.tool_registry.clone())
594        .system_prompt(system_prompt)
595        .prompt_segments(prompt_segments)
596        .max_steps(state.config.max_steps)
597        .build()
598        .map_err(|_| ApiError::internal("failed to build forked session runtime"))?;
599
600    // Count non-system messages BEFORE set_transcript (which moves the
601    // snapshot). The new session's `non_system_message_count` atomic and
602    // the fork response's `message_count` both use this number so they
603    // agree with `SessionInfo.message_count` from `GET /sessions`.
604    let non_system_count = transcript_snapshot
605        .iter()
606        .filter(|m| m.role != crate::message::Role::System)
607        .count();
608    runtime.set_transcript(transcript_snapshot);
609
610    let plan_approval_gate = runtime.plan_approval_gate();
611    let session = SessionState {
612        id: new_id.clone(),
613        created_at: created_at.clone(),
614        title: None,
615        runtime: Arc::new(tokio::sync::Mutex::new(runtime)),
616        plan_approval_gate,
617        interrupt_token: Arc::new(tokio::sync::Mutex::new(None)),
618        non_system_message_count: Arc::new(std::sync::atomic::AtomicUsize::new(non_system_count)),
619        last_active_ms: Arc::new(AtomicU64::new(super::now_session_ms())),
620        prompt_tokens: Arc::new(AtomicU64::new(0)),
621        completion_tokens: Arc::new(AtomicU64::new(0)),
622    };
623
624    state.sessions.write().await.insert(new_id.clone(), session);
625    state
626        .metrics
627        .sessions_active
628        .fetch_add(1, Ordering::Relaxed);
629
630    Ok((
631        StatusCode::CREATED,
632        Json(ForkSessionResponse {
633            id: new_id,
634            created_at,
635            message_count: non_system_count,
636        }),
637    ))
638}
639
640// ── Plan-approval endpoints ───────────────────────────────────────────────
641
642#[derive(serde::Deserialize)]
643pub(super) struct PlanConfirmRequest {
644    /// Optional replacement plan text to use instead of the agent-proposed one.
645    edits: Option<String>,
646}
647
648#[derive(serde::Deserialize)]
649pub(super) struct PlanRejectRequest {
650    /// Reason shown to the agent so it can revise the plan.
651    reason: Option<String>,
652}
653
654/// POST /sessions/:id/plan/confirm — approve the pending plan.
655pub(super) async fn session_plan_confirm(
656    State(state): State<Arc<AppState>>,
657    Path(session_id): Path<String>,
658    Json(body): Json<PlanConfirmRequest>,
659) -> Result<Json<serde_json::Value>, ApiError> {
660    let sessions = state.sessions.read().await;
661    let session = sessions
662        .get(&session_id)
663        .ok_or_else(|| ApiError::not_found("session not found"))?;
664    let pending = session
665        .plan_approval_gate
666        .pending_plan
667        .read()
668        .ok()
669        .and_then(|g| g.clone());
670    if pending.is_none() {
671        return Err(ApiError::conflict("session is not awaiting plan approval"));
672    }
673    // Optionally replace the plan text before approving.
674    if let Some(edited) = body.edits {
675        if let Ok(mut w) = session.plan_approval_gate.pending_plan.write() {
676            *w = Some(edited);
677        }
678    }
679    session.plan_approval_gate.approve();
680    Ok(Json(serde_json::json!({
681        "status": "approved",
682        "session_id": session_id
683    })))
684}
685
686/// POST /sessions/:id/plan/reject — reject the pending plan.
687pub(super) async fn session_plan_reject(
688    State(state): State<Arc<AppState>>,
689    Path(session_id): Path<String>,
690    Json(body): Json<PlanRejectRequest>,
691) -> Result<Json<serde_json::Value>, ApiError> {
692    let sessions = state.sessions.read().await;
693    let session = sessions
694        .get(&session_id)
695        .ok_or_else(|| ApiError::not_found("session not found"))?;
696    let pending = session
697        .plan_approval_gate
698        .pending_plan
699        .read()
700        .ok()
701        .and_then(|g| g.clone());
702    if pending.is_none() {
703        return Err(ApiError::conflict("session is not awaiting plan approval"));
704    }
705    let reason = body.reason.unwrap_or_default();
706    session.plan_approval_gate.reject(&reason);
707    Ok(Json(serde_json::json!({
708        "status": "rejected",
709        "session_id": session_id
710    })))
711}
712
713// ── Goal-168: goal endpoints ──────────────────────────────────────────────
714
715/// POST /sessions/:id/goal — start a condition-based autonomous loop.
716pub(super) async fn session_set_goal(
717    State(state): State<Arc<AppState>>,
718    Path(session_id): Path<String>,
719    Json(body): Json<SetGoalRequest>,
720) -> Result<Json<serde_json::Value>, ApiError> {
721    let runtime_arc = {
722        let sessions = state.sessions.read().await;
723        let session = sessions
724            .get(&session_id)
725            .ok_or_else(|| ApiError::not_found("session not found"))?;
726        session.runtime.clone()
727    };
728
729    let condition = body.condition.clone();
730    let max_turns = body.max_turns.unwrap_or(20);
731
732    // Lock runtime and set goal state (non-blocking; loop runs in background).
733    match runtime_arc.try_lock() {
734        Ok(runtime) => {
735            runtime.set_goal(condition, max_turns).await;
736        }
737        Err(_) => {
738            return Err(ApiError::conflict("session runtime is busy"));
739        }
740    }
741
742    Ok(Json(serde_json::json!({
743        "status": "pursuing",
744        "session_id": session_id
745    })))
746}
747
748/// DELETE /sessions/:id/goal — clear the active goal.
749pub(super) async fn session_clear_goal(
750    State(state): State<Arc<AppState>>,
751    Path(session_id): Path<String>,
752) -> Result<Json<serde_json::Value>, ApiError> {
753    let runtime_arc = {
754        let sessions = state.sessions.read().await;
755        let session = sessions
756            .get(&session_id)
757            .ok_or_else(|| ApiError::not_found("session not found"))?;
758        session.runtime.clone()
759    };
760
761    let lock_result = runtime_arc.try_lock();
762
763    match lock_result {
764        Ok(runtime) => {
765            runtime.clear_goal().await;
766            drop(runtime);
767            Ok(Json(serde_json::json!({
768                "status": "cleared",
769                "session_id": session_id
770            })))
771        }
772        Err(_) => {
773            // Runtime is busy with an in-flight turn; retry briefly.
774            if runtime_goal_state_clear(&runtime_arc).await {
775                return Ok(Json(serde_json::json!({
776                    "status": "cleared",
777                    "session_id": session_id
778                })));
779            }
780            // Fold the original `hint` text into the error message so the
781            // standardized `{"error": "..."}` envelope preserves it (Goal-313).
782            // Attach `Retry-After: 5` via ApiError::with_retry_after so
783            // clients that respect the hint can back off correctly.
784            Err(ApiError::conflict(
785                "session runtime is busy; goal not cleared — retry after the current turn completes",
786            )
787            .with_retry_after(5))
788        }
789    }
790}
791
792/// Force-clear goal state when the runtime Mutex is held.
793///
794/// Retries up to 10 times × 100ms (1s total). Returns `true` if the
795/// goal was cleared, `false` if the runtime is still busy.
796async fn runtime_goal_state_clear(
797    runtime: &Arc<tokio::sync::Mutex<crate::runtime::AgentRuntime>>,
798) -> bool {
799    for _ in 0..10u8 {
800        if let Ok(rt) = runtime.try_lock() {
801            rt.clear_goal().await;
802            return true;
803        }
804        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
805    }
806    false
807}
808
809// ── Goal-170: interrupt endpoint ───────────────────────────────────────────
810
811/// POST /sessions/:id/interrupt — cancel the active agent turn.
812///
813/// Cancels the `CancellationToken` installed at the start of the current
814/// turn. The kernel exits with `FinishReason::Cancelled` at the next step
815/// boundary.  If no turn is in progress the request is still `200 OK`
816/// (idempotent — no harm done).
817pub(super) async fn session_interrupt(
818    State(state): State<Arc<AppState>>,
819    Path(session_id): Path<String>,
820) -> Result<Json<serde_json::Value>, ApiError> {
821    let token_arc = {
822        let sessions = state.sessions.read().await;
823        let session = sessions
824            .get(&session_id)
825            .ok_or_else(|| ApiError::not_found("session not found"))?;
826        session.interrupt_token.clone()
827    };
828
829    // Cancel the current token if one is installed.
830    let token_opt = token_arc.lock().await.clone();
831    if let Some(token) = token_opt {
832        token.cancel();
833    }
834
835    Ok(Json(serde_json::json!({
836        "status": "interrupted",
837        "session_id": session_id
838    })))
839}
840
841// ── Goal-169: slash commands endpoint ─────────────────────────────────────
842
843/// GET /slash-commands — list all registered slash commands.
844// Pure clone of AppState field — no branching worth scoring.
845#[cfg_attr(test, mutants::skip)]
846pub(super) async fn list_slash_commands(
847    State(state): State<Arc<AppState>>,
848) -> Json<Vec<SlashCommandInfo>> {
849    Json((*state.slash_commands).clone())
850}
851
852/// POST /sessions/:id/messages — send a message in a session.
853pub(super) async fn send_session_message(
854    State(state): State<Arc<AppState>>,
855    Path(id): Path<String>,
856    Json(body): Json<SessionMessageRequest>,
857) -> Result<Json<SessionMessageResponse>, ApiError> {
858    if body.content.trim().is_empty() {
859        return Err(ApiError::bad_request("missing or empty 'content' field"));
860    }
861    tracing::debug!(session_id = %id, content_len = body.content.len(), "session message received");
862    // Get the session's runtime, interrupt token, message counter, last_active,
863    // and token usage counters.
864    let (runtime_arc, interrupt_token_arc, msg_count_arc, prompt_tokens_arc, completion_tokens_arc) = {
865        let sessions = state.sessions.read().await;
866        let session = sessions
867            .get(&id)
868            .ok_or_else(|| ApiError::not_found("session not found"))?;
869        // Update last_active_ms timestamp for this session.
870        session
871            .last_active_ms
872            .store(super::now_session_ms(), Ordering::Relaxed);
873        (
874            session.runtime.clone(),
875            session.interrupt_token.clone(),
876            session.non_system_message_count.clone(),
877            session.prompt_tokens.clone(),
878            session.completion_tokens.clone(),
879        )
880    };
881
882    // Ensure broadcast channel exists for this session before we lock the runtime.
883    let broadcast_tx = {
884        let mut channels = state.event_channels.write().await;
885        let tx = channels.entry(id.clone()).or_insert_with(|| {
886            let (tx, _) = broadcast::channel(64);
887            tx
888        });
889        tx.clone()
890    };
891
892    // Lock the runtime for this turn (serializes concurrent requests per session).
893
894    // Acquire a semaphore permit to limit concurrent runs.
895    let _permit = state
896        .run_semaphore
897        .clone()
898        .acquire_owned()
899        .await
900        .map_err(|_| {
901            ApiError::new(
902                StatusCode::SERVICE_UNAVAILABLE,
903                "too many concurrent runs, try again later",
904            )
905        })?;
906    let mut runtime = runtime_arc.lock().await;
907
908    // Goal-170: install a fresh cancellation token so `POST .../interrupt`
909    // can cancel this turn without affecting future turns.
910    let interrupt_token = tokio_util::sync::CancellationToken::new();
911    {
912        let mut stored = interrupt_token_arc.lock().await;
913        *stored = Some(interrupt_token.clone());
914    }
915    runtime.set_interrupt_token(interrupt_token);
916
917    // Wire a ChannelSink so events are forwarded to SSE subscribers.
918    let (sink, mut event_rx) = ChannelSink::new();
919    runtime.set_event_sink(Arc::new(sink));
920
921    // Spawn a forwarder: AgentEvent → SseEvent → broadcast channel.
922    // SDK Phase B: track tool call start times so we can emit tool_progress
923    // events with elapsed_ms when each tool finishes.
924    // Goal 274: also maintain the non_system_message_count atomic so the
925    // count stays correct even when the turn errors out mid-run.
926    let initial_count = msg_count_arc.load(std::sync::atomic::Ordering::Relaxed);
927    let count_arc = msg_count_arc.clone();
928    let forward_handle = tokio::spawn(async move {
929        let mut tool_start_times: HashMap<String, std::time::Instant> = HashMap::new();
930        let mut count: usize = initial_count;
931        while let Some(ref agent_event) = event_rx.recv().await {
932            // Increment the count for every non-System message appended.
933            match agent_event {
934                AgentEvent::MessageAppended { message, .. }
935                | AgentEvent::MessageAppendedWithAudit { message, .. }
936                    if message.role != Role::System =>
937                {
938                    count += 1;
939                    count_arc.store(count, std::sync::atomic::Ordering::Relaxed);
940                }
941                _ => {}
942            }
943            // Record start time for each tool call so we can compute elapsed
944            // when the result arrives.
945            if let AgentEvent::ToolCall { id, .. } = agent_event {
946                tool_start_times.insert(id.clone(), std::time::Instant::now());
947            }
948            if let Some(sse_event) = map_agent_event(agent_event) {
949                let _ = broadcast_tx.send(sse_event);
950            }
951            // After forwarding the tool_result, emit tool_progress with timing.
952            if let AgentEvent::ToolResult { id, name, .. } = agent_event {
953                let elapsed_ms = tool_start_times
954                    .remove(id)
955                    .map(|start| start.elapsed().as_millis() as u64)
956                    .unwrap_or(0);
957                let _ = broadcast_tx.send(SseEvent::ToolProgress {
958                    tool_use_id: id.clone(),
959                    tool_name: name.clone(),
960                    elapsed_ms,
961                });
962            }
963        }
964    });
965
966    // Run the agent turn via enqueue so the runtime's FIFO queue is used.
967    let run_result = runtime.enqueue(&body.content).await.map(|opt| {
968        opt.unwrap_or_else(|| crate::runtime::RuntimeOutcome {
969            final_text: None,
970            finish_reason: crate::agent::FinishReason::NoMoreToolCalls,
971            total_usage: crate::TokenUsage::default(),
972            steps: 0,
973            llm_latency_ms: 0,
974            checkpoint_id: None,
975        })
976    });
977
978    // Clear the interrupt token slot — the turn is done.
979    {
980        let mut stored = interrupt_token_arc.lock().await;
981        *stored = None;
982    }
983
984    // Disconnect the sink so the forwarder drains and exits.
985    runtime.set_event_sink(Arc::new(NullSink));
986    let _ = forward_handle.await;
987
988    let outcome = run_result.map_err(|e| {
989        record_run_failed(&state.metrics);
990        map_run_error(&e)
991    })?;
992
993    // Update per-session token counters and global metrics.
994    prompt_tokens_arc.fetch_add(outcome.total_usage.prompt_tokens as u64, Ordering::Relaxed);
995    completion_tokens_arc.fetch_add(
996        outcome.total_usage.completion_tokens as u64,
997        Ordering::Relaxed,
998    );
999    record_run_success(&state.metrics, outcome.steps, &outcome.total_usage);
1000
1001    // Extract the last assistant message from the runtime's transcript.
1002    let last_assistant = runtime
1003        .transcript()
1004        .iter()
1005        .rev()
1006        .find(|m| m.role == crate::message::Role::Assistant)
1007        .map(|m| m.content.clone())
1008        .unwrap_or_default();
1009
1010    Ok(Json(SessionMessageResponse {
1011        role: "assistant".into(),
1012        content: last_assistant,
1013    }))
1014}
1015
1016// ── SSE endpoint ─────────────────────────────────────────────────────────
1017
1018/// GET /sessions/:id/events — subscribe to SSE stream of agent events.
1019pub(super) async fn session_events(
1020    State(state): State<Arc<AppState>>,
1021    Path(id): Path<String>,
1022) -> Result<Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>>, ApiError> {
1023    // Verify session exists
1024    {
1025        let sessions = state.sessions.read().await;
1026        if !sessions.contains_key(&id) {
1027            return Err(ApiError::not_found("session not found"));
1028        }
1029    }
1030
1031    // Get or create broadcast channel for this session
1032    let rx = {
1033        let mut channels = state.event_channels.write().await;
1034        let tx = channels.entry(id.clone()).or_insert_with(|| {
1035            let (tx, _) = broadcast::channel(64);
1036            tx
1037        });
1038        tx.subscribe()
1039    };
1040
1041    // Map real agent events to SSE data events, dropping lagged-receiver errors.
1042    let agent_stream = BroadcastStream::new(rx).filter_map(|result| match result {
1043        Ok(sse_event) => {
1044            let event_type = match &sse_event {
1045                SseEvent::Message { .. } => "message",
1046                SseEvent::PartialMessage { .. } => "partial_message",
1047                SseEvent::ToolCall { .. } => "tool_call",
1048                SseEvent::ToolResult { .. } => "tool_result",
1049                SseEvent::Done { .. } => "done",
1050                SseEvent::Error { .. } => "error",
1051                SseEvent::PlanProposed { .. } => "plan_proposed",
1052                SseEvent::GoalContinuing { .. } => "goal_continuing",
1053                SseEvent::GoalAchieved { .. } => "goal_achieved",
1054                SseEvent::ToolProgress { .. } => "tool_progress",
1055            };
1056            let data = serde_json::to_string(&sse_event).unwrap_or_default();
1057            Some(Ok::<Event, Infallible>(
1058                Event::default().event(event_type).data(data),
1059            ))
1060        }
1061        Err(_) => None,
1062    });
1063
1064    // Heartbeat: emit an SSE comment every 30 seconds so proxy/load-balancer
1065    // layers can detect the connection is still alive.
1066    let heartbeat_stream = IntervalStream::new(tokio::time::interval(Duration::from_secs(30)))
1067        .map(|_| Ok::<Event, Infallible>(Event::default().comment("heartbeat")));
1068
1069    // Merge agent events and heartbeats into a single stream, capped at 1 hour.
1070    let combined = agent_stream
1071        .merge(heartbeat_stream)
1072        .timeout(Duration::from_secs(3600))
1073        .filter_map(|r| r.ok());
1074
1075    Ok(Sse::new(combined))
1076}
1077
1078// ── Event mapping ────────────────────────────────────────────────────────
1079
1080/// Map an [`AgentEvent`] to an [`SseEvent`] for broadcasting to SSE clients.
1081///
1082/// Returns `None` for events that have no SSE equivalent (latency, tokens, etc.).
1083pub fn map_agent_event(event: &AgentEvent) -> Option<SseEvent> {
1084    match event {
1085        // Streaming token deltas — clients reconstruct the final text by
1086        // concatenating deltas keyed on `step`.
1087        AgentEvent::PartialToken { text, step } => Some(SseEvent::PartialMessage {
1088            text: text.clone(),
1089            step: *step,
1090        }),
1091        // A canonical persisted message — emit it as a typed Message event so
1092        // SDK consumers iterating `Run.stream()` get role-tagged content
1093        // (assistant text, tool_use blocks). User and tool messages flow
1094        // through here too; we only forward roles that are useful to a
1095        // streaming consumer.
1096        //
1097        // We deliberately do NOT also map `AgentEvent::AssistantText` — the
1098        // runtime emits both `AssistantText` (per-step) and `MessageAppended`
1099        // (once per committed message), so consuming both would produce
1100        // duplicate Message events on every assistant turn.
1101        AgentEvent::MessageAppended { message, .. }
1102        | AgentEvent::MessageAppendedWithAudit { message, .. } => {
1103            sse_message_from_canonical(message)
1104        }
1105        AgentEvent::ToolCall { name, step, .. } => Some(SseEvent::ToolCall {
1106            name: name.clone(),
1107            step: *step,
1108        }),
1109        AgentEvent::ToolResult { name, is_error, .. } => {
1110            let success = !is_error;
1111            Some(SseEvent::ToolResult {
1112                name: name.clone(),
1113                success,
1114            })
1115        }
1116        AgentEvent::TurnFinished { reason, steps } => Some(SseEvent::Done {
1117            finish_reason: reason.clone(),
1118            total_steps: *steps,
1119        }),
1120        AgentEvent::PlanProposed { plan_text, .. } => Some(SseEvent::PlanProposed {
1121            plan: plan_text.clone(),
1122        }),
1123        // Goal-168: forward goal-loop progress events.
1124        AgentEvent::GoalContinuing { reason, turns } => Some(SseEvent::GoalContinuing {
1125            reason: reason.clone(),
1126            turns: *turns,
1127        }),
1128        AgentEvent::GoalAchieved { condition, turns } => Some(SseEvent::GoalAchieved {
1129            condition: condition.clone(),
1130            turns: *turns,
1131        }),
1132        // AssistantText, Latency, Usage, Compacted, PlanConfirmed,
1133        // PlanRejected don't have SSE equivalents (AssistantText is
1134        // intentionally suppressed in favour of MessageAppended above).
1135        _ => None,
1136    }
1137}
1138
1139/// Convert a canonical [`crate::message::Message`] into an [`SseEvent::Message`].
1140///
1141/// `system` and `tool` messages are filtered out — system messages carry
1142/// internal seeds the SDK consumer never asked for, and tool *result*
1143/// messages are already represented by [`SseEvent::ToolResult`].
1144fn sse_message_from_canonical(msg: &crate::message::Message) -> Option<SseEvent> {
1145    use crate::message::Role;
1146    let role = match msg.role {
1147        Role::Assistant => "assistant",
1148        Role::User => "user",
1149        Role::System | Role::Tool => return None,
1150    };
1151
1152    let mut content: Vec<SseContentBlock> = Vec::new();
1153    if !msg.content.is_empty() {
1154        content.push(SseContentBlock::Text {
1155            text: msg.content.clone(),
1156        });
1157    }
1158    for tc in &msg.tool_calls {
1159        content.push(SseContentBlock::ToolUse {
1160            id: tc.id.clone(),
1161            name: tc.name.clone(),
1162            input: tc.arguments.clone(),
1163        });
1164    }
1165    if content.is_empty() {
1166        return None;
1167    }
1168    Some(SseEvent::Message {
1169        role: role.into(),
1170        content,
1171    })
1172}
1173
1174// ── AG-UI endpoint ───────────────────────────────────────────────────────
1175
1176/// State machine that the AG-UI converter uses to coordinate
1177/// `TextMessageStart/Content/End` framing across multiple AgentEvents.
1178///
1179/// We open a TextMessage on the first `AssistantText`/`PartialToken` we see
1180/// after every "neutral" point (run start, after `TextMessageEnd`, after
1181/// tool-call events) and close it explicitly when we emit a fully-formed
1182/// `AssistantText`, when a `ToolCall` arrives, or when the run finishes.
1183#[derive(Default)]
1184struct AguiConverter {
1185    /// `Some(message_id)` when a TextMessageStart has been emitted but no
1186    /// TextMessageEnd yet. Used as the `messageId` for streaming
1187    /// `PartialToken` deltas and as the `parentMessageId` for tool calls.
1188    open_message_id: Option<String>,
1189    /// Last fully-emitted (or currently-open) assistant message id. Used as
1190    /// the `parent_message_id` on ToolCallStart even after the message has
1191    /// been closed, so a client can attribute the tool call back to the
1192    /// triggering assistant turn.
1193    last_assistant_message_id: Option<String>,
1194}
1195
1196impl AguiConverter {
1197    fn new() -> Self {
1198        Self::default()
1199    }
1200
1201    /// Translate one [`AgentEvent`] into zero or more AG-UI events,
1202    /// updating internal framing state as a side effect.
1203    fn convert(&mut self, ev: &AgentEvent) -> Vec<agui_protocol::Event> {
1204        use agui_protocol as ag;
1205        let mut out = Vec::new();
1206        match ev {
1207            AgentEvent::AssistantText { text, .. } => {
1208                // Close any in-flight streamed message first.
1209                if let Some(id) = self.open_message_id.take() {
1210                    out.push(ag::Event::TextMessageEnd(ag::TextMessageEnd {
1211                        message_id: id,
1212                        base: ag::BaseEvent::default(),
1213                    }));
1214                }
1215                let id = uuid::Uuid::new_v4().to_string();
1216                out.push(ag::Event::TextMessageStart(ag::TextMessageStart {
1217                    message_id: id.clone(),
1218                    role: Some("assistant".into()),
1219                    base: ag::BaseEvent::default(),
1220                }));
1221                out.push(ag::Event::TextMessageContent(ag::TextMessageContent {
1222                    message_id: id.clone(),
1223                    delta: text.clone(),
1224                    base: ag::BaseEvent::default(),
1225                }));
1226                out.push(ag::Event::TextMessageEnd(ag::TextMessageEnd {
1227                    message_id: id.clone(),
1228                    base: ag::BaseEvent::default(),
1229                }));
1230                self.last_assistant_message_id = Some(id);
1231                self.open_message_id = None;
1232            }
1233            AgentEvent::PartialToken { text, .. } => {
1234                let id = if let Some(id) = self.open_message_id.clone() {
1235                    id
1236                } else {
1237                    let id = uuid::Uuid::new_v4().to_string();
1238                    out.push(ag::Event::TextMessageStart(ag::TextMessageStart {
1239                        message_id: id.clone(),
1240                        role: Some("assistant".into()),
1241                        base: ag::BaseEvent::default(),
1242                    }));
1243                    self.open_message_id = Some(id.clone());
1244                    self.last_assistant_message_id = Some(id.clone());
1245                    id
1246                };
1247                out.push(ag::Event::TextMessageContent(ag::TextMessageContent {
1248                    message_id: id,
1249                    delta: text.clone(),
1250                    base: ag::BaseEvent::default(),
1251                }));
1252            }
1253            AgentEvent::ToolCall {
1254                id,
1255                name,
1256                arguments,
1257                ..
1258            } => {
1259                // Close any in-flight streamed assistant message first; the
1260                // assistant turn is "done" the moment a tool call lands.
1261                if let Some(open) = self.open_message_id.take() {
1262                    out.push(ag::Event::TextMessageEnd(ag::TextMessageEnd {
1263                        message_id: open,
1264                        base: ag::BaseEvent::default(),
1265                    }));
1266                }
1267                out.push(ag::Event::ToolCallStart(ag::ToolCallStart {
1268                    tool_call_id: id.clone(),
1269                    tool_call_name: name.clone(),
1270                    parent_message_id: self.last_assistant_message_id.clone(),
1271                    base: ag::BaseEvent::default(),
1272                }));
1273                out.push(ag::Event::ToolCallArgs(ag::ToolCallArgs {
1274                    tool_call_id: id.clone(),
1275                    delta: arguments.clone(),
1276                    base: ag::BaseEvent::default(),
1277                }));
1278                out.push(ag::Event::ToolCallEnd(ag::ToolCallEnd {
1279                    tool_call_id: id.clone(),
1280                    base: ag::BaseEvent::default(),
1281                }));
1282            }
1283            AgentEvent::ToolResult { id, output, .. } => {
1284                // AG-UI requires a `messageId` on ToolCallResult; reuse the
1285                // most recent assistant message id as the conversational
1286                // anchor (mirrors what OpenAI's tool message shape does).
1287                let message_id = self
1288                    .last_assistant_message_id
1289                    .clone()
1290                    .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1291                out.push(ag::Event::ToolCallResult(ag::ToolCallResult {
1292                    tool_call_id: id.clone(),
1293                    message_id,
1294                    content: output.clone(),
1295                    role: Some("tool".into()),
1296                    base: ag::BaseEvent::default(),
1297                }));
1298            }
1299            AgentEvent::TurnFinished { .. } => {
1300                // Close any in-flight streamed message before signalling
1301                // run completion to the client.
1302                if let Some(open) = self.open_message_id.take() {
1303                    out.push(ag::Event::TextMessageEnd(ag::TextMessageEnd {
1304                        message_id: open,
1305                        base: ag::BaseEvent::default(),
1306                    }));
1307                }
1308                // Actual RunFinished is emitted by the caller (it knows
1309                // the thread/run ids); we just flush state here.
1310            }
1311            // Hook lifecycle events: forward as Custom so AG-UI clients can
1312            // render hook progress and system messages in real time.
1313            AgentEvent::HookStarted {
1314                hook_event,
1315                hook_name,
1316                status_message,
1317                ..
1318            } => {
1319                out.push(ag::Event::Custom(ag::Custom {
1320                    name: "agui-tui/hook_started".into(),
1321                    value: serde_json::json!({
1322                        "hookEvent": hook_event,
1323                        "hookName": hook_name,
1324                        "statusMessage": status_message,
1325                    }),
1326                    base: ag::BaseEvent::default(),
1327                }));
1328            }
1329            AgentEvent::HookProgress {
1330                hook_event,
1331                hook_name,
1332                last_line,
1333                ..
1334            } => {
1335                out.push(ag::Event::Custom(ag::Custom {
1336                    name: "agui-tui/hook_progress".into(),
1337                    value: serde_json::json!({
1338                        "hookEvent": hook_event,
1339                        "hookName": hook_name,
1340                        "lastLine": last_line,
1341                    }),
1342                    base: ag::BaseEvent::default(),
1343                }));
1344            }
1345            AgentEvent::HookFinished {
1346                hook_event,
1347                hook_name,
1348                outcome,
1349                duration_ms,
1350                ..
1351            } => {
1352                out.push(ag::Event::Custom(ag::Custom {
1353                    name: "agui-tui/hook_finished".into(),
1354                    value: serde_json::json!({
1355                        "hookEvent": hook_event,
1356                        "hookName": hook_name,
1357                        "outcome": outcome,
1358                        "durationMs": duration_ms,
1359                    }),
1360                    base: ag::BaseEvent::default(),
1361                }));
1362            }
1363            AgentEvent::HookSystemMessage { text, .. } => {
1364                out.push(ag::Event::Custom(ag::Custom {
1365                    name: "agui-tui/hook_system_message".into(),
1366                    value: serde_json::json!({ "text": text }),
1367                    base: ag::BaseEvent::default(),
1368                }));
1369            }
1370            // Task checklist updates: forward so clients can render live todo state.
1371            AgentEvent::TodoUpdated { todos, .. } => {
1372                out.push(ag::Event::Custom(ag::Custom {
1373                    name: "agui-tui/todo_updated".into(),
1374                    value: serde_json::json!({ "todos": todos }),
1375                    base: ag::BaseEvent::default(),
1376                }));
1377            }
1378            // checkpoint_post is emitted directly by the driver task (not via
1379            // AguiConverter) after RunFinished so it lands last.
1380            // heartbeat is emitted as an SSE comment at the HTTP layer.
1381            // permission_request and file_artifact require new AgentEvent variants
1382            // (tracked as g141/g140) before they can be mapped here.
1383            // Other variants (Latency, Usage, Compacted, PlanProposed,
1384            // PlanConfirmed, PlanRejected, etc.) have no AG-UI standard
1385            // equivalent and are intentionally dropped.
1386            _ => {}
1387        }
1388        out
1389    }
1390}
1391
1392/// Stateless wrapper: maps a single [`AgentEvent`] to AG-UI events
1393/// using a fresh converter. Useful in tests; production code uses
1394/// [`AguiConverter::convert`] directly so framing state survives
1395/// across the whole run.
1396#[cfg(test)]
1397fn agui_events_for(ev: &AgentEvent) -> Vec<agui_protocol::Event> {
1398    AguiConverter::new().convert(ev)
1399}
1400
1401/// POST /agui — drive an agent run via the AG-UI protocol and stream
1402/// AG-UI events back as SSE.
1403pub(super) async fn agui_run(
1404    State(state): State<Arc<AppState>>,
1405    Json(body): Json<serde_json::Value>,
1406) -> Result<
1407    Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>>,
1408    (StatusCode, Json<ErrorResponse>),
1409> {
1410    use agui_protocol as ag;
1411
1412    // Parse the body into a typed RunAgentInput. We accept Json<Value>
1413    // up top so we can return a clean 400 with a helpful message
1414    // instead of axum's default 422 on shape errors.
1415    let input: ag::RunAgentInput = serde_json::from_value(body).map_err(|e| {
1416        (
1417            StatusCode::BAD_REQUEST,
1418            Json(ErrorResponse {
1419                status: "error".into(),
1420                error: format!("invalid AG-UI RunAgentInput: {e}"),
1421            }),
1422        )
1423    })?;
1424
1425    // ── Resume handling ──────────────────────────────────────────────────
1426    // If `input.resume` is present and non-empty, process the interrupt
1427    // resolutions before building the runtime.
1428    let resume_items: Vec<ag::Resume> = input.resume.unwrap_or_default();
1429
1430    // Derive the user goal: prefer the last user message, else fall back
1431    // to the first context item value. Resume turns must NOT re-append the
1432    // original user message (the seeded transcript already contains it plus
1433    // the injected tool result; a duplicate makes the model re-issue the
1434    // same tool call), so a neutral continuation directive is used instead.
1435    let goal = if resume_items.is_empty() {
1436        input
1437            .messages
1438            .iter()
1439            .rev()
1440            .find(|m| m.role == "user")
1441            .and_then(|m| m.content.clone())
1442            .or_else(|| input.context.first().map(|c| c.value.clone()))
1443            .filter(|s| !s.trim().is_empty())
1444            .ok_or_else(|| {
1445                (
1446                    StatusCode::BAD_REQUEST,
1447                    Json(ErrorResponse {
1448                        status: "error".into(),
1449                        error: "RunAgentInput must contain at least one user \
1450                                message or a non-empty context item"
1451                            .into(),
1452                    }),
1453                )
1454            })?
1455    } else {
1456        "[frontend tool result received] 客户端工具结果已注入对话,请基于该结果继续回答用户最初的问题。".to_string()
1457    };
1458    let mut seed_transcript: Option<Vec<crate::message::Message>> = None;
1459
1460    if !resume_items.is_empty() {
1461        let session_dir =
1462            agui_session_dir(&state.config.workspace, &input.thread_id).ok_or_else(|| {
1463                (
1464                    StatusCode::INTERNAL_SERVER_ERROR,
1465                    Json(ErrorResponse {
1466                        status: "error".into(),
1467                        error: "cannot resolve session directory for resume".into(),
1468                    }),
1469                )
1470            })?;
1471
1472        if !session_dir.join("transcript.jsonl").is_file() {
1473            return Err((
1474                StatusCode::BAD_REQUEST,
1475                Json(ErrorResponse {
1476                    status: "error".into(),
1477                    error: format!(
1478                        "no prior run found for thread '{}'; cannot resume",
1479                        input.thread_id
1480                    ),
1481                }),
1482            ));
1483        }
1484
1485        // Load open interrupts from session metadata.
1486        let open_interrupts = load_open_interrupts(&session_dir);
1487        if open_interrupts.is_empty() {
1488            return Err((
1489                StatusCode::BAD_REQUEST,
1490                Json(ErrorResponse {
1491                    status: "error".into(),
1492                    error: format!(
1493                        "thread '{}' has no open interrupts; nothing to resume",
1494                        input.thread_id
1495                    ),
1496                }),
1497            ));
1498        }
1499
1500        // Spec rule 3: a single resume must address EVERY open interrupt.
1501        let resume_ids: std::collections::HashSet<String> = resume_items
1502            .iter()
1503            .map(|r| r.interrupt_id.clone())
1504            .collect();
1505        for open_int in &open_interrupts {
1506            if !resume_ids.contains(&open_int.interrupt_id) {
1507                return Err((
1508                    StatusCode::BAD_REQUEST,
1509                    Json(ErrorResponse {
1510                        status: "error".into(),
1511                        error: format!(
1512                            "resume must cover all open interrupts; missing '{}'",
1513                            open_int.interrupt_id
1514                        ),
1515                    }),
1516                ));
1517            }
1518        }
1519
1520        // Load the transcript from the session. The AG-UI run persists one
1521        // `Message` JSON per line (see the write side in the driver task).
1522        let transcript_path = session_dir.join("transcript.jsonl");
1523        let loaded_messages: Vec<crate::message::Message> =
1524            std::fs::read_to_string(&transcript_path)
1525                .map_err(|e| {
1526                    (
1527                        StatusCode::INTERNAL_SERVER_ERROR,
1528                        Json(ErrorResponse {
1529                            status: "error".into(),
1530                            error: format!("failed to load session transcript: {e}"),
1531                        }),
1532                    )
1533                })?
1534                .lines()
1535                .filter(|line| !line.trim().is_empty())
1536                .filter_map(|line| serde_json::from_str(line).ok())
1537                .collect();
1538
1539        // Build an index of resume items by interrupt_id.
1540        let resume_by_id: std::collections::HashMap<&str, &ag::Resume> = resume_items
1541            .iter()
1542            .map(|r| (r.interrupt_id.as_str(), r))
1543            .collect();
1544
1545        // For each resolved interrupt, find the matching tool result in the
1546        // transcript and replace/inject the content. The tool was denied by
1547        // the TestInterruptHook, so we look for the tool result whose
1548        // `tool_call_id` matches the interrupt's bound tool_call_id.
1549        let mut modified = loaded_messages;
1550        for open_int in &open_interrupts {
1551            let Some(resume) = resume_by_id.get(open_int.interrupt_id.as_str()) else {
1552                continue;
1553            };
1554            let tool_call_id = &open_int.tool_call_id;
1555
1556            if resume.status == ag::ResumeStatus::Cancelled {
1557                // For cancelled interrupts, inject a sentinel tool result.
1558                let sentinel = crate::message::Message::tool_result(
1559                    tool_call_id,
1560                    "[interrupt cancelled by user]",
1561                );
1562                modified.push(sentinel);
1563            } else if let Some(ref payload) = resume.payload {
1564                // Resolved: replace the denied tool result content with the
1565                // resume payload, or inject a new tool result if none exists.
1566                let payload_str = serde_json::to_string(payload).unwrap_or_default();
1567                let replaced = modified.iter_mut().any(|msg| {
1568                    if msg.tool_call_id.as_deref() == Some(tool_call_id.as_str()) {
1569                        msg.content = payload_str.clone();
1570                        true
1571                    } else {
1572                        false
1573                    }
1574                });
1575                if !replaced {
1576                    // No existing tool result found — inject one.
1577                    modified.push(crate::message::Message::tool_result(
1578                        tool_call_id,
1579                        &payload_str,
1580                    ));
1581                }
1582            }
1583        }
1584
1585        // Clear the open interrupts now that they've been consumed.
1586        clear_open_interrupts(&session_dir);
1587
1588        seed_transcript = Some(modified);
1589    }
1590
1591    // ── Interrupt-before check (spec rule 4) ────────────────────────────
1592    // If the thread has open interrupts and no resume is provided, reject.
1593    if resume_items.is_empty() {
1594        if let Some(session_dir) = agui_session_dir(&state.config.workspace, &input.thread_id) {
1595            let open = load_open_interrupts(&session_dir);
1596            if !open.is_empty() {
1597                return Err((
1598                    StatusCode::CONFLICT,
1599                    Json(ErrorResponse {
1600                        status: "error".into(),
1601                        error: format!(
1602                            "thread '{}' has {} open interrupt(s); \
1603                             must provide resume to continue",
1604                            input.thread_id,
1605                            open.len()
1606                        ),
1607                    }),
1608                ));
1609            }
1610        }
1611    }
1612
1613    // Acquire a semaphore permit to limit concurrent runs.
1614    // Goal-H J2: use `try_acquire_owned` so a saturated semaphore
1615    // returns immediately with a 503 (rather than awaiting
1616    // indefinitely via `acquire_owned().await`, which would hang
1617    // every /agui request when the pool is full). The previous
1618    // behaviour was documented in the g268 lead-completion
1619    // journal entry but the fix was deferred. Closing it here.
1620    let _permit = state
1621        .run_semaphore
1622        .clone()
1623        .try_acquire_owned()
1624        .map_err(|_| {
1625            (
1626                StatusCode::SERVICE_UNAVAILABLE,
1627                Json(ErrorResponse {
1628                    status: "error".into(),
1629                    error: "too many concurrent runs, try again later".into(),
1630                }),
1631            )
1632        })?;
1633
1634    // Common system-prompt assembly: project context + skill index +
1635    // coordinator/sub_agent note (when enabled).
1636    let assembled_system_prompt = crate::assemble_system_prompt(
1637        &state.config.system_prompt,
1638        &state.config.workspace,
1639        &state.skills,
1640        state.config.subagent_enabled,
1641    );
1642    let system_prompt = assembled_system_prompt.full;
1643    let prompt_segments = assembled_system_prompt.segments;
1644
1645    // If interrupt_before is set, install a test-only permission hook on a
1646    // clone of the registry so matching tools are denied. The driver task
1647    // later checks if any tool was denied and emits an interrupt RunFinished.
1648    let interrupt_hook: Option<Arc<TestInterruptHook>> =
1649        input.interrupt_before.as_ref().map(|names| {
1650            Arc::new(TestInterruptHook {
1651                interrupt_before: names.clone(),
1652                interrupted_tool_call_id: std::sync::Mutex::new(None),
1653                interrupted_tool_name: std::sync::Mutex::new(None),
1654                interrupted_arguments: std::sync::Mutex::new(None),
1655            })
1656        });
1657
1658    // ── AG-UI client tools: register stubs + install deny hook ────────
1659    let agui_tools = input.tools.clone();
1660    let client_tool_names: std::collections::HashSet<String> =
1661        agui_tools.iter().map(|t| t.name.clone()).collect();
1662
1663    let mut tool_registry = state.tool_registry.clone();
1664    for t in &agui_tools {
1665        tool_registry = tool_registry.register(Arc::new(ClientToolStub {
1666            name: t.name.clone(),
1667            description: t.description.clone(),
1668            parameters: t.parameters.clone(),
1669        }));
1670    }
1671
1672    // Client tools take the permission-hook slot; `interrupt_before` is a
1673    // test-only facility and is ignored when client tools are present.
1674    let client_hook: Option<Arc<ClientToolHook>> = if client_tool_names.is_empty() {
1675        None
1676    } else {
1677        Some(Arc::new(ClientToolHook {
1678            names: client_tool_names,
1679            denied: std::sync::Mutex::new(None),
1680        }))
1681    };
1682    if let Some(ref client_hook_ref) = client_hook {
1683        let hook: Arc<ClientToolHook> = client_hook_ref.clone();
1684        tool_registry.set_permission_hook(hook);
1685    }
1686    if client_hook.is_none() {
1687        if let Some(ref test_hook_ref) = interrupt_hook {
1688            let hook: Arc<TestInterruptHook> = test_hook_ref.clone();
1689            tool_registry.set_permission_hook(hook);
1690        }
1691    }
1692
1693    let mut runtime_builder = AgentRuntimeBuilder::new()
1694        .llm(state.provider.clone())
1695        .tools(tool_registry)
1696        .system_prompt(system_prompt)
1697        .prompt_segments(prompt_segments)
1698        .max_steps(state.config.max_steps);
1699
1700    // Seed the transcript if we're resuming.
1701    if let Some(seed) = seed_transcript {
1702        runtime_builder = runtime_builder.seed_transcript(seed);
1703    }
1704
1705    let mut runtime = runtime_builder.build().map_err(|e| {
1706        (
1707            StatusCode::INTERNAL_SERVER_ERROR,
1708            Json(ErrorResponse {
1709                status: "error".into(),
1710                error: format!("failed to build runtime: {e}"),
1711            }),
1712        )
1713    })?;
1714
1715    // Route the permission hook into the runtime's TurnContext: client
1716    // tools must be denied at dispatch so they surface as interrupts.
1717    if let Some(ref hook) = client_hook {
1718        runtime.set_permission_hook(hook.clone());
1719    } else if let Some(ref hook) = interrupt_hook {
1720        runtime.set_permission_hook(hook.clone());
1721    }
1722
1723    // Wire per-turn workspace checkpoints. The AG-UI thread is the
1724    // natural session boundary, so we use a sanitised version of the
1725    // thread_id as the checkpoint chain id. Failures (no git on PATH,
1726    // bad workspace path, etc.) only log a warning — the run still
1727    // proceeds without checkpoints.
1728    if let Ok(repo) = crate::ShadowRepo::open(&state.config.workspace) {
1729        let session_id = sanitize_thread_id_for_session(&input.thread_id);
1730        if let Ok(session_dir) = crate::user_sessions_dir(&state.config.workspace) {
1731            let log_dir = session_dir.join(format!("agui-{session_id}"));
1732            let _ = std::fs::create_dir_all(&log_dir);
1733            let log_path = log_dir.join("checkpoints.jsonl");
1734            let touched = runtime.kernel().tools().touched_files();
1735            if let Err(e) =
1736                runtime.enable_checkpoints(Arc::new(repo), session_id, log_path, touched)
1737            {
1738                tracing::warn!("agui: enable_checkpoints failed, continuing without: {e}");
1739            }
1740        }
1741    } else {
1742        tracing::debug!("agui: shadow git unavailable, no per-turn checkpoints");
1743    }
1744
1745    let (sink, mut event_rx) = ChannelSink::new();
1746    runtime.set_event_sink(Arc::new(sink));
1747
1748    // Channel that carries fully-converted AG-UI Events to the SSE stream.
1749    let (sse_tx, sse_rx) = tokio::sync::mpsc::unbounded_channel::<ag::Event>();
1750    let thread_id = input.thread_id.clone();
1751    let run_id = input.run_id.clone();
1752
1753    // Emit RunStarted up front so clients can render the run shell
1754    // before the first model token arrives.
1755    let _ = sse_tx.send(ag::Event::RunStarted(ag::RunStarted {
1756        thread_id: thread_id.clone(),
1757        run_id: run_id.clone(),
1758        base: ag::BaseEvent::default(),
1759    }));
1760
1761    // Converter task: forward AgentEvents → AG-UI Events. Owns the
1762    // AguiConverter so framing state survives across the whole run.
1763    // It does NOT emit RunFinished — the driver task does that after
1764    // it can also surface the optional checkpoint_post Custom event.
1765    let conv_tx = sse_tx.clone();
1766    let converter_handle = tokio::spawn(async move {
1767        let mut conv = AguiConverter::new();
1768        while let Some(agent_event) = event_rx.recv().await {
1769            for ev in conv.convert(&agent_event) {
1770                if conv_tx.send(ev).is_err() {
1771                    return;
1772                }
1773            }
1774        }
1775    });
1776
1777    // Drive the agent on a background task so the response stream can
1778    // flush bytes to the client incrementally. Order of events emitted
1779    // by the driver after run() returns:
1780    //   1. Wait for the converter to drain all AgentEvents.
1781    //   2. If a checkpoint id was produced, emit
1782    //      Custom("agui-tui/checkpoint_post").
1783    //   3. Emit RunFinished — always last.
1784    let metrics = state.metrics.clone();
1785    // Capture the thread/run ids and interrupt hook for the driver task.
1786    let drv_thread = thread_id.clone();
1787    let drv_run = run_id.clone();
1788    let drv_interrupt_hook = interrupt_hook.clone();
1789    let drv_client_hook = client_hook.clone();
1790    let drv_client_tools = agui_tools.clone();
1791    let drv_workspace = state.config.workspace.clone();
1792
1793    let driver_handle = tokio::spawn(async move {
1794        let outcome = runtime.run(&goal).await;
1795
1796        // Locate the interrupted tool call after the run:
1797        // - test hook: transcript contains the fixed deny marker
1798        // - client tools: transcript contains CLIENT_TOOL_DENY_PREFIX
1799        // Both markers land in the Tool-role message that the registry
1800        // writes for a denied call, and carry the real tool_call_id.
1801        let find_denied_tool_call = |transcript: &[crate::message::Message], marker: &str| {
1802            transcript
1803                .iter()
1804                .rev()
1805                .find(|msg| msg.role == crate::message::Role::Tool && msg.content.contains(marker))
1806                .and_then(|msg| msg.tool_call_id.clone())
1807        };
1808
1809        let client_denied: Option<(String, String)> = drv_client_hook.as_ref().and_then(|h| {
1810            let guard = h.denied.lock().unwrap_or_else(|e| e.into_inner());
1811            guard.clone()
1812        });
1813        let test_was_interrupted = drv_interrupt_hook
1814            .as_ref()
1815            .and_then(|hook| {
1816                hook.interrupted_tool_name
1817                    .lock()
1818                    .unwrap_or_else(|e| e.into_inner())
1819                    .clone()
1820            })
1821            .is_some();
1822        // Replace the sink so the converter task's recv() sees a closed
1823        // channel and exits cleanly.
1824        runtime.set_event_sink(Arc::new(NullSink));
1825
1826        // Snapshot what we need from the outcome before metrics consume it.
1827        let (checkpoint_id, finished_turn): (Option<String>, Option<usize>) = match &outcome {
1828            Ok(o) => (
1829                o.checkpoint_id.as_ref().map(|c| c.0.clone()),
1830                runtime.turn_index().checked_sub(1),
1831            ),
1832            Err(_) => (None, None),
1833        };
1834
1835        match &outcome {
1836            Ok(o) => record_run_success(&metrics, o.steps, &o.total_usage),
1837            Err(_) => record_run_failed(&metrics),
1838        }
1839
1840        // Persist the transcript so a later `resume` (client-tool result
1841        // round-trip) can load and seed it. Format: one Message JSON per
1842        // line — the resume loader reads the same shape.
1843        if let Some(session_dir) = agui_session_dir(&drv_workspace, &drv_thread) {
1844            let _ = std::fs::create_dir_all(&session_dir);
1845            let mut lines = String::new();
1846            for m in runtime.transcript() {
1847                if let Ok(v) = serde_json::to_string(m) {
1848                    lines.push_str(&v);
1849                    lines.push('\n');
1850                }
1851            }
1852            let path = session_dir.join("transcript.jsonl");
1853            if let Err(e) = std::fs::write(&path, lines) {
1854                tracing::warn!("agui: transcript persist failed: {e}");
1855            }
1856        }
1857
1858        // Interrupt details for whichever mechanism fired. `parameters`
1859        // carries the client tool's input schema so the frontend knows how
1860        // to execute it; `args` echoes the model's arguments.
1861        let interrupt_details: Option<AguiInterruptDetail> =
1862            if let Some((name, args)) = client_denied {
1863                let transcript = runtime.transcript();
1864                find_denied_tool_call(transcript, CLIENT_TOOL_DENY_PREFIX).map(|tc_id| {
1865                    let parameters = drv_client_tools
1866                        .iter()
1867                        .find(|t| t.name == name)
1868                        .map(|t| t.parameters.clone())
1869                        .unwrap_or_else(|| serde_json::json!({"type": "object"}));
1870                    AguiInterruptDetail::Client {
1871                        tool_call_id: tc_id,
1872                        tool_name: name,
1873                        parameters,
1874                        args: serde_json::Value::String(args),
1875                    }
1876                })
1877            } else if test_was_interrupted {
1878                let transcript = runtime.transcript();
1879                let denied_tool_name = drv_interrupt_hook.as_ref().and_then(|h| {
1880                    h.interrupted_tool_name
1881                        .lock()
1882                        .unwrap_or_else(|e| e.into_inner())
1883                        .clone()
1884                });
1885                find_denied_tool_call(transcript, "test interrupt trigger").map(|tc_id| {
1886                    AguiInterruptDetail::Test {
1887                        tool_call_id: tc_id,
1888                        tool_name: denied_tool_name.unwrap_or_else(|| "unknown".into()),
1889                    }
1890                })
1891            } else {
1892                None
1893            };
1894
1895        // Wait for the converter task to translate the last AgentEvent
1896        // before we emit anything else, so checkpoint_post and
1897        // RunFinished are guaranteed to arrive last.
1898        let _ = converter_handle.await;
1899
1900        if let (Some(cp), Some(turn)) = (checkpoint_id, finished_turn) {
1901            let _ = sse_tx.send(ag::Event::Custom(ag::Custom {
1902                name: "agui-tui/checkpoint_post".into(),
1903                value: serde_json::json!({
1904                    "turn": turn,
1905                    "postId": cp,
1906                }),
1907                base: ag::BaseEvent::default(),
1908            }));
1909        }
1910
1911        // Emit RunFinished — with Interrupt outcome if a test trigger or a
1912        // client-tool call fired.
1913        if let Some(detail) = interrupt_details {
1914            let (tc_id, _tc_name, response_schema, metadata, message) = match detail {
1915                AguiInterruptDetail::Test {
1916                    tool_call_id,
1917                    tool_name,
1918                } => (
1919                    tool_call_id,
1920                    tool_name.clone(),
1921                    serde_json::json!({
1922                        "type": "object",
1923                        "properties": { "approved": { "type": "boolean" } }
1924                    }),
1925                    serde_json::json!({ "testTrigger": true, "toolName": tool_name }),
1926                    format!("Test interrupt: tool '{tool_name}' needs user input to proceed"),
1927                ),
1928                AguiInterruptDetail::Client {
1929                    tool_call_id,
1930                    tool_name,
1931                    parameters,
1932                    args,
1933                } => (
1934                    tool_call_id,
1935                    tool_name.clone(),
1936                    parameters,
1937                    serde_json::json!({ "frontendTool": true, "toolName": tool_name, "args": args }),
1938                    format!("Frontend tool '{tool_name}' needs client execution to proceed"),
1939                ),
1940            };
1941            let tool_call_id = tc_id.clone();
1942            let interrupt_message = message.clone();
1943
1944            // Build the interrupt and persist it.
1945            let interrupt_id = uuid::Uuid::new_v4().to_string();
1946            let open_interrupt = OpenInterrupt {
1947                interrupt_id: interrupt_id.clone(),
1948                tool_call_id: tool_call_id.clone(),
1949                reason: "tool_call".into(),
1950                message: Some(interrupt_message.clone()),
1951                created_at: crate::session::chrono_lite_now(),
1952            };
1953
1954            // Persist before emitting (crash safety).
1955            if let Some(session_dir) = agui_session_dir(&drv_workspace, &drv_thread) {
1956                let _ = std::fs::create_dir_all(&session_dir);
1957                save_open_interrupts(&session_dir, std::slice::from_ref(&open_interrupt));
1958
1959                // Emit StateSnapshot and MessagesSnapshot before RunFinished
1960                // per spec requirement (snapshots must precede the interrupting
1961                // RunFinished event).
1962                if let Ok(state_val) = serde_json::to_value(runtime.transcript()) {
1963                    let _ = sse_tx.send(ag::Event::StateSnapshot(ag::StateSnapshot {
1964                        snapshot: state_val,
1965                        base: ag::BaseEvent::default(),
1966                    }));
1967                }
1968                let messages_json: Vec<serde_json::Value> = runtime
1969                    .transcript()
1970                    .iter()
1971                    .filter_map(|m| serde_json::to_value(m).ok())
1972                    .collect();
1973                let _ = sse_tx.send(ag::Event::MessagesSnapshot(ag::MessagesSnapshot {
1974                    messages: messages_json,
1975                    base: ag::BaseEvent::default(),
1976                }));
1977            }
1978
1979            let _ = sse_tx.send(ag::Event::RunFinished(ag::RunFinished {
1980                thread_id: drv_thread,
1981                run_id: drv_run,
1982                outcome: Some(ag::RunFinishedOutcome::Interrupt {
1983                    interrupts: vec![ag::Interrupt {
1984                        id: interrupt_id,
1985                        reason: "tool_call".into(),
1986                        message: Some(interrupt_message),
1987                        tool_call_id: Some(tool_call_id),
1988                        response_schema: Some(response_schema),
1989                        expires_at: None,
1990                        metadata: Some(metadata),
1991                    }],
1992                }),
1993                result: None,
1994                base: ag::BaseEvent::default(),
1995            }));
1996        } else {
1997            // Report the run's real outcome. When runtime.run() returned
1998            // Err (LLM failure, tool failure, provider down, ...), the
1999            // RunFinished must carry an Error outcome so the client can
2000            // distinguish a failed run from a successful one with no
2001            // result — otherwise the failure is silently swallowed at
2002            // the SSE boundary. `code` is reserved for a follow-up goal
2003            // that maps Error::Cancelled / RateLimited / etc. to codes.
2004            let (run_outcome, result_msg) = match &outcome {
2005                Ok(o) => (
2006                    ag::RunFinishedOutcome::Success,
2007                    o.final_text.clone().map(serde_json::Value::String),
2008                ),
2009                Err(e) => (
2010                    ag::RunFinishedOutcome::Error {
2011                        message: e.to_string(),
2012                        code: None,
2013                    },
2014                    None,
2015                ),
2016            };
2017            let _ = sse_tx.send(ag::Event::RunFinished(ag::RunFinished {
2018                thread_id: drv_thread,
2019                run_id: drv_run,
2020                outcome: Some(run_outcome),
2021                result: result_msg,
2022                base: ag::BaseEvent::default(),
2023            }));
2024        }
2025    });
2026
2027    // Monitor the driver task so panics are surfaced in logs rather than
2028    // silently swallowed by the dropped JoinHandle.
2029    tokio::spawn(async move {
2030        if let Err(e) = driver_handle.await {
2031            tracing::error!("agui: driver task panicked: {e}");
2032        }
2033    });
2034
2035    let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(sse_rx).map(|ev| {
2036        let data = serde_json::to_string(&ev).unwrap_or_else(|_| "{}".into());
2037        Ok::<_, Infallible>(Event::default().data(data))
2038    });
2039
2040    Ok(Sse::new(stream))
2041}
2042
2043/// Map an arbitrary AG-UI thread id onto a checkpoint session id that
2044/// satisfies `validate_session_id` in the checkpoint module
2045/// (alphanumerics + `-` `_` `.`, no leading dot, no `..`, no path
2046/// separators). Disallowed chars become `-`.
2047fn sanitize_thread_id_for_session(thread: &str) -> String {
2048    let mut out: String = thread
2049        .chars()
2050        .map(|c| {
2051            if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
2052                c
2053            } else {
2054                '-'
2055            }
2056        })
2057        .collect();
2058    // Drop a leading dot so we don't produce a hidden dir.
2059    while out.starts_with('.') {
2060        out.replace_range(..1, "-");
2061    }
2062    // Collapse `..` so we don't produce ref-traversal sequences.
2063    while out.contains("..") {
2064        out = out.replace("..", "-.");
2065    }
2066    if out.is_empty() {
2067        out.push_str("default");
2068    }
2069    out
2070}
2071
2072// ── AG-UI Interrupt/Resume helpers ──────────────────────────────────────
2073
2074/// Path to the JSONL session directory for an AG-UI thread.
2075fn agui_session_dir(workspace: &std::path::Path, thread_id: &str) -> Option<std::path::PathBuf> {
2076    let session_id = sanitize_thread_id_for_session(thread_id);
2077    crate::user_sessions_dir(workspace)
2078        .ok()
2079        .map(|d| d.join(format!("agui-{session_id}")))
2080}
2081
2082/// Which interrupt mechanism fired during an AG-UI run.
2083#[derive(Debug, Clone)]
2084enum AguiInterruptDetail {
2085    Test {
2086        tool_call_id: String,
2087        tool_name: String,
2088    },
2089    Client {
2090        tool_call_id: String,
2091        tool_name: String,
2092        parameters: serde_json::Value,
2093        args: serde_json::Value,
2094    },
2095}
2096
2097/// One open interrupt persisted in the session metadata.
2098#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2099struct OpenInterrupt {
2100    pub interrupt_id: String,
2101    pub tool_call_id: String,
2102    pub reason: String,
2103    pub message: Option<String>,
2104    pub created_at: String,
2105}
2106
2107/// Load open interrupts from session metadata.
2108fn load_open_interrupts(session_dir: &std::path::Path) -> Vec<OpenInterrupt> {
2109    let path = session_dir.join(".interrupts.json");
2110    let Ok(bytes) = std::fs::read(&path) else {
2111        return Vec::new();
2112    };
2113    serde_json::from_slice(&bytes).unwrap_or_default()
2114}
2115
2116/// Save open interrupts to session metadata. Written atomically before
2117/// emitting RunFinished with Interrupt outcome.
2118fn save_open_interrupts(session_dir: &std::path::Path, interrupts: &[OpenInterrupt]) {
2119    if let Ok(json) = serde_json::to_string_pretty(interrupts) {
2120        let path = session_dir.join(".interrupts.json");
2121        let _ = std::fs::create_dir_all(session_dir);
2122        crate::atomic::atomic_write(&path, json.as_bytes()).ok();
2123    }
2124}
2125
2126/// Clear open interrupts (called after a successful resume that consumed
2127/// all pending interrupts).
2128fn clear_open_interrupts(session_dir: &std::path::Path) {
2129    let path = session_dir.join(".interrupts.json");
2130    let _ = std::fs::remove_file(&path);
2131}
2132
2133/// Test-only permission hook: denies tools whose names appear in
2134/// `interrupt_before`, records the first such denial as an 'interrupt'.
2135/// This is the test-only trigger — the real permission_pipeline.Ask
2136/// integration is g325.
2137struct TestInterruptHook {
2138    interrupt_before: Vec<String>,
2139    /// Set to the first tool_call_id that was denied (if any).
2140    interrupted_tool_call_id: std::sync::Mutex<Option<String>>,
2141    /// Set to the name of the first tool that was denied.
2142    interrupted_tool_name: std::sync::Mutex<Option<String>>,
2143    /// Set to the arguments of the first tool that was denied.
2144    interrupted_arguments: std::sync::Mutex<Option<serde_json::Value>>,
2145}
2146
2147// ── AG-UI client tools bridge ────────────────────────────────────────
2148// `RunAgentInput.tools` are frontend-owned functions (CopilotKit
2149// useCopilotAction etc.). We register server-side stubs so the model can
2150// call them, and a permission hook that denies the call before dispatch —
2151// the deny produces an interrupt whose payload is the tool result the
2152// frontend sends back via `input.resume` (docs/copilot-agent-plan.md M2-1).
2153
2154pub(crate) const CLIENT_TOOL_DENY_PREFIX: &str = "[frontend tool]";
2155
2156struct ClientToolStub {
2157    name: String,
2158    description: String,
2159    parameters: serde_json::Value,
2160}
2161
2162#[async_trait::async_trait]
2163impl crate::tools::Tool for ClientToolStub {
2164    fn spec(&self) -> crate::llm::chat::ToolSpec {
2165        crate::llm::chat::ToolSpec {
2166            name: self.name.clone(),
2167            description: self.description.clone(),
2168            parameters: self.parameters.clone(),
2169        }
2170    }
2171    async fn execute(&self, _arguments: serde_json::Value) -> crate::error::Result<String> {
2172        // Defensive fallback: ClientToolHook denies client tools before
2173        // dispatch, so execute() should never run.
2174        Ok(format!(
2175            "{} {} not executed server-side",
2176            CLIENT_TOOL_DENY_PREFIX, self.name
2177        ))
2178    }
2179}
2180
2181struct ClientToolHook {
2182    names: std::collections::HashSet<String>,
2183    /// First denied call: (tool_name, args_json). The tool_call_id is not
2184    /// known at check() time; the driver locates it by scanning the
2185    /// transcript for the deny-reason marker.
2186    denied: std::sync::Mutex<Option<(String, String)>>,
2187}
2188
2189#[async_trait::async_trait]
2190impl crate::tools::PermissionHook for ClientToolHook {
2191    async fn check(
2192        &self,
2193        name: &str,
2194        args: &serde_json::Value,
2195    ) -> crate::agent::PermissionDecision {
2196        if !self.names.contains(name) {
2197            return crate::agent::PermissionDecision::Allow;
2198        }
2199        {
2200            let mut slot = self.denied.lock().unwrap_or_else(|e| e.into_inner());
2201            if slot.is_none() {
2202                *slot = Some((name.to_string(), args.to_string()));
2203            }
2204        }
2205        crate::agent::PermissionDecision::Deny(format!(
2206            "{} {} awaiting client execution",
2207            CLIENT_TOOL_DENY_PREFIX, name
2208        ))
2209    }
2210}
2211
2212#[async_trait::async_trait]
2213#[async_trait]
2214impl crate::tools::PermissionHook for TestInterruptHook {
2215    async fn check(
2216        &self,
2217        name: &str,
2218        args: &serde_json::Value,
2219    ) -> crate::agent::PermissionDecision {
2220        if self
2221            .interrupted_tool_call_id
2222            .lock()
2223            .unwrap_or_else(|e| e.into_inner())
2224            .is_some()
2225        {
2226            // Already interrupted — don't deny further tools.
2227            return crate::agent::PermissionDecision::Allow;
2228        }
2229        if self.interrupt_before.iter().any(|n| n == name) {
2230            // Record the interrupt — the tool_call_id is synthetic because
2231            // the actual tool_call hasn't been assigned an id yet at this
2232            // point. The drive task will capture the real id from the stream.
2233            *self
2234                .interrupted_tool_name
2235                .lock()
2236                .unwrap_or_else(|e| e.into_inner()) = Some(name.to_string());
2237            *self
2238                .interrupted_arguments
2239                .lock()
2240                .unwrap_or_else(|e| e.into_inner()) = Some(args.clone());
2241            return crate::agent::PermissionDecision::Deny(
2242                "test interrupt trigger — tool blocked by interrupt_before".into(),
2243            );
2244        }
2245        crate::agent::PermissionDecision::Allow
2246    }
2247}
2248
2249/// GET /metrics — Prometheus exposition format.
2250pub(super) async fn metrics_handler(State(state): State<Arc<AppState>>) -> String {
2251    let metrics = &state.metrics;
2252    let requests_total = metrics.requests_total.load(Ordering::Relaxed);
2253    let requests_active = metrics.requests_active.load(Ordering::Relaxed);
2254    let agent_runs_total = metrics.agent_runs_total.load(Ordering::Relaxed);
2255    let agent_runs_success = metrics.agent_runs_success.load(Ordering::Relaxed);
2256    let agent_runs_failed = metrics.agent_runs_failed.load(Ordering::Relaxed);
2257    let tokens_prompt_total = metrics.tokens_prompt_total.load(Ordering::Relaxed);
2258    let tokens_completion_total = metrics.tokens_completion_total.load(Ordering::Relaxed);
2259    let agent_steps_total = metrics.agent_steps_total.load(Ordering::Relaxed);
2260    let sessions_active = metrics.sessions_active.load(Ordering::Relaxed);
2261    let rate_limits_rejected = metrics.rate_limits_rejected.load(Ordering::Relaxed);
2262
2263    format!(
2264        "# HELP recursive_requests_total Total HTTP requests\n\
2265         # TYPE recursive_requests_total counter\n\
2266         recursive_requests_total {requests_total}\n\
2267         # HELP recursive_requests_active Currently active HTTP requests\n\
2268         # TYPE recursive_requests_active gauge\n\
2269         recursive_requests_active {requests_active}\n\
2270         # HELP recursive_agent_runs_total Total agent runs\n\
2271         # TYPE recursive_agent_runs_total counter\n\
2272         recursive_agent_runs_total {agent_runs_total}\n\
2273         # HELP recursive_agent_runs_success Successful agent runs\n\
2274         # TYPE recursive_agent_runs_success counter\n\
2275         recursive_agent_runs_success {agent_runs_success}\n\
2276         # HELP recursive_agent_runs_failed Failed agent runs\n\
2277         # TYPE recursive_agent_runs_failed counter\n\
2278         recursive_agent_runs_failed {agent_runs_failed}\n\
2279         # HELP recursive_tokens_prompt_total Total prompt tokens consumed\n\
2280         # TYPE recursive_tokens_prompt_total counter\n\
2281         recursive_tokens_prompt_total {tokens_prompt_total}\n\
2282         # HELP recursive_tokens_completion_total Total completion tokens generated\n\
2283         # TYPE recursive_tokens_completion_total counter\n\
2284         recursive_tokens_completion_total {tokens_completion_total}\n\
2285         # HELP recursive_agent_steps_total Total agent steps executed\n\
2286         # TYPE recursive_agent_steps_total counter\n\
2287         recursive_agent_steps_total {agent_steps_total}\n\
2288         # HELP recursive_sessions_active Currently active sessions\n\
2289         # TYPE recursive_sessions_active gauge\n\
2290         recursive_sessions_active {sessions_active}\n\
2291         # HELP recursive_rate_limits_rejected_total Total requests rejected by rate limiting\n\
2292         # TYPE recursive_rate_limits_rejected_total counter\n\
2293         recursive_rate_limits_rejected_total {rate_limits_rejected}\n"
2294    )
2295}
2296
2297#[cfg(test)]
2298mod tests {
2299    use super::*;
2300    use crate::event::AgentEvent;
2301    use crate::http::SseEvent;
2302
2303    #[test]
2304    fn agui_events_for_assistant_text_emits_start_content_end() {
2305        use agui_protocol as ag;
2306        let ev = AgentEvent::AssistantText {
2307            text: "hi".into(),
2308            step: 0,
2309        };
2310        let out = agui_events_for(&ev);
2311        assert_eq!(out.len(), 3, "got {out:?}");
2312        assert!(matches!(out[0], ag::Event::TextMessageStart(_)));
2313        assert!(matches!(out[1], ag::Event::TextMessageContent(_)));
2314        assert!(matches!(out[2], ag::Event::TextMessageEnd(_)));
2315    }
2316
2317    // ── SDK Phase B: tool_progress forwarder ─────────────────────────────
2318
2319    /// Verify that the stateful forwarder logic correctly emits ToolProgress
2320    /// after ToolResult with the right tool_name.  We simulate the forwarder's
2321    /// HashMap bookkeeping without spinning up a full Tokio task.
2322    #[test]
2323    fn tool_progress_emitted_after_tool_result() {
2324        use std::collections::HashMap;
2325        use std::time::Instant;
2326
2327        let mut tool_start_times: HashMap<String, Instant> = HashMap::new();
2328        let mut emitted: Vec<SseEvent> = Vec::new();
2329
2330        // Simulate ToolCall arrival
2331        let call_event = AgentEvent::ToolCall {
2332            name: "Bash".to_string(),
2333            id: "tc-1".to_string(),
2334            arguments: "{}".to_string(),
2335            step: 0,
2336        };
2337        if let AgentEvent::ToolCall { id, .. } = &call_event {
2338            tool_start_times.insert(id.clone(), Instant::now());
2339        }
2340        if let Some(ev) = map_agent_event(&call_event) {
2341            emitted.push(ev);
2342        }
2343
2344        // Simulate ToolResult arrival (no sleep needed — elapsed_ms ≥ 0)
2345        let result_event = AgentEvent::ToolResult {
2346            id: "tc-1".to_string(),
2347            name: "Bash".to_string(),
2348            output: "ok".to_string(),
2349            step: 0,
2350            is_error: false,
2351        };
2352        if let Some(ev) = map_agent_event(&result_event) {
2353            emitted.push(ev);
2354        }
2355        if let AgentEvent::ToolResult { id, name, .. } = &result_event {
2356            let elapsed_ms = tool_start_times
2357                .remove(id)
2358                .map(|start| start.elapsed().as_millis() as u64)
2359                .unwrap_or(0);
2360            emitted.push(SseEvent::ToolProgress {
2361                tool_use_id: id.clone(),
2362                tool_name: name.clone(),
2363                elapsed_ms,
2364            });
2365        }
2366
2367        // Expect: ToolCall, ToolResult, ToolProgress
2368        assert_eq!(emitted.len(), 3, "expected 3 events");
2369        assert!(matches!(emitted[0], SseEvent::ToolCall { .. }));
2370        assert!(matches!(emitted[1], SseEvent::ToolResult { .. }));
2371        let SseEvent::ToolProgress {
2372            tool_use_id,
2373            tool_name,
2374            elapsed_ms,
2375        } = &emitted[2]
2376        else {
2377            panic!("third event should be ToolProgress");
2378        };
2379        assert_eq!(tool_use_id, "tc-1");
2380        assert_eq!(tool_name, "Bash");
2381        let _ = elapsed_ms; // ≥ 0 is trivially true for u64
2382    }
2383
2384    /// Verify that tool_start_times does NOT grow if a ToolResult arrives
2385    /// without a matching ToolCall (e.g. replayed events).
2386    #[test]
2387    fn tool_progress_elapsed_is_zero_for_unmatched_result() {
2388        use std::collections::HashMap;
2389        use std::time::Instant;
2390
2391        let mut tool_start_times: HashMap<String, Instant> = HashMap::new();
2392
2393        let result_event = AgentEvent::ToolResult {
2394            id: "tc-orphan".to_string(),
2395            name: "Read".to_string(),
2396            output: "data".to_string(),
2397            step: 0,
2398            is_error: false,
2399        };
2400        let elapsed_ms = if let AgentEvent::ToolResult { id, .. } = &result_event {
2401            tool_start_times
2402                .remove(id)
2403                .map(|start| start.elapsed().as_millis() as u64)
2404                .unwrap_or(0)
2405        } else {
2406            unreachable!()
2407        };
2408        // No panic; elapsed defaults to 0 when no matching ToolCall.
2409        assert_eq!(elapsed_ms, 0);
2410    }
2411
2412    /// Goal-268 + Goal-H J2: /agui must respect run_semaphore. The
2413    /// handler uses `try_acquire_owned` (J2) so a saturated (0-
2414    /// permit) semaphore returns 503 SERVICE_UNAVAILABLE
2415    /// **immediately** rather than blocking forever on
2416    /// `acquire_owned().await`. A 0-permit `Semaphore` is the
2417    /// natural test fixture — no `close()` workaround needed
2418    /// (the previous form tested a *closed* semaphore, which is
2419    /// a different code path inside `try_acquire_owned`).
2420    #[tokio::test]
2421    async fn agui_run_respects_run_semaphore() {
2422        use crate::llm::MockProvider;
2423        use crate::tools::ToolRegistry;
2424        use std::sync::Arc;
2425        use tokio::sync::Semaphore;
2426
2427        std::env::set_var("RECURSIVE_API_KEY", "test-key");
2428        std::env::set_var("RECURSIVE_MODEL", "test-model");
2429        let config = crate::config::Config::from_env().unwrap();
2430
2431        // 0-permit semaphore: every `try_acquire_owned` call
2432        // returns `TryAcquireError::NoPermits` immediately.
2433        let sem = Arc::new(Semaphore::new(0));
2434
2435        let state = Arc::new(crate::http::AppState {
2436            tools: vec![],
2437            tool_registry: ToolRegistry::default(),
2438            config,
2439            provider: Arc::new(MockProvider::new(vec![])),
2440            sessions: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
2441            event_channels: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
2442            metrics: Arc::new(crate::http::Metrics::default()),
2443            slash_commands: Arc::new(vec![]),
2444            session_ttl_secs: 3600,
2445            run_semaphore: sem,
2446            rate_limiter: crate::http::RateLimiter::new(10, 1.0),
2447            skills: vec![],
2448        });
2449
2450        let body = serde_json::json!({
2451            "threadId": "t1",
2452            "runId": "r1",
2453            "messages": [{"id": "m1", "role": "user", "content": "hi"}],
2454        });
2455        let (status, _err) = agui_run(State(state), Json(body))
2456            .await
2457            .expect_err("expected SERVICE_UNAVAILABLE");
2458        assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
2459    }
2460
2461    // ── Goal-280: clear_goal returns 409 when runtime busy ────────────
2462
2463    /// Simulate a busy runtime (mutex held by an in-flight turn) and
2464    /// verify `session_clear_goal` returns 409 with Retry-After: 5.
2465    /// Then release the lock and verify the next call returns 200.
2466    ///
2467    /// Goal-313: the handler now returns `Result<Json<...>, ApiError>`
2468    /// instead of `Response` directly. We convert via
2469    /// `axum::response::IntoResponse` so the same assertions (status,
2470    /// headers, body) still work.
2471    #[tokio::test]
2472    async fn clear_goal_returns_409_when_runtime_busy() {
2473        use crate::llm::MockProvider;
2474        use crate::tools::ToolRegistry;
2475        use axum::response::IntoResponse;
2476        use std::sync::Arc;
2477        use tokio::sync::Semaphore;
2478
2479        std::env::set_var("RECURSIVE_API_KEY", "test-key");
2480        std::env::set_var("RECURSIVE_MODEL", "test-model");
2481        let config = crate::config::Config::from_env().unwrap();
2482        let provider = Arc::new(MockProvider::new(vec![]));
2483
2484        let session_id = "test-busy-session".to_string();
2485        let runtime = AgentRuntimeBuilder::new()
2486            .llm(provider.clone())
2487            .tools(ToolRegistry::default())
2488            .build()
2489            .expect("runtime build");
2490        let runtime_arc = Arc::new(tokio::sync::Mutex::new(runtime));
2491        let session = SessionState {
2492            id: session_id.clone(),
2493            created_at: "2026-01-01T00:00:00Z".to_string(),
2494            title: None,
2495            runtime: runtime_arc.clone(),
2496            plan_approval_gate: Default::default(),
2497            interrupt_token: Arc::new(tokio::sync::Mutex::new(None)),
2498            non_system_message_count: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
2499            last_active_ms: Arc::new(AtomicU64::new(0)),
2500            prompt_tokens: Arc::new(AtomicU64::new(0)),
2501            completion_tokens: Arc::new(AtomicU64::new(0)),
2502        };
2503
2504        let sessions: HashMap<String, SessionState> = [(session_id.clone(), session)].into();
2505        let state = Arc::new(AppState {
2506            tools: vec![],
2507            tool_registry: ToolRegistry::default(),
2508            config,
2509            provider,
2510            sessions: Arc::new(tokio::sync::RwLock::new(sessions)),
2511            event_channels: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
2512            metrics: Arc::new(crate::http::Metrics::default()),
2513            slash_commands: Arc::new(vec![]),
2514            session_ttl_secs: 3600,
2515            run_semaphore: Arc::new(Semaphore::new(8)),
2516            rate_limiter: crate::http::RateLimiter::new(10, 1.0),
2517            skills: vec![],
2518        });
2519
2520        // Acquire the runtime mutex to simulate a busy runtime.
2521        let guard = runtime_arc.lock().await;
2522
2523        // Call the handler while the mutex is held → should get 409
2524        // (Result::Err(ApiError::conflict(...).with_retry_after(5))).
2525        let resp = session_clear_goal(State(state.clone()), Path(session_id.clone()))
2526            .await
2527            .into_response();
2528        let status = resp.status();
2529        assert_eq!(status, StatusCode::CONFLICT, "expected 409 Conflict");
2530        let retry_after = resp
2531            .headers()
2532            .get(axum::http::header::RETRY_AFTER)
2533            .expect("Retry-After header missing")
2534            .to_str()
2535            .unwrap();
2536        assert_eq!(retry_after, "5", "expected Retry-After: 5");
2537
2538        // Drop the guard and retry → should get 200.
2539        drop(guard);
2540        let resp = session_clear_goal(State(state), Path(session_id))
2541            .await
2542            .into_response();
2543        assert_eq!(resp.status(), StatusCode::OK, "expected 200 after unlock");
2544    }
2545
2546    /// Helper: build a minimal AppState with one session for handler unit tests.
2547    fn test_app_state_with_session(
2548        session_id: &str,
2549    ) -> (
2550        Arc<AppState>,
2551        Arc<tokio::sync::Mutex<crate::runtime::AgentRuntime>>,
2552    ) {
2553        use crate::llm::MockProvider;
2554        use crate::tools::ToolRegistry;
2555        use tokio::sync::Semaphore;
2556
2557        std::env::set_var("RECURSIVE_API_KEY", "test-key");
2558        std::env::set_var("RECURSIVE_MODEL", "test-model");
2559        let config = crate::config::Config::from_env().unwrap();
2560        let provider = Arc::new(MockProvider::new(vec![]));
2561        let runtime = AgentRuntimeBuilder::new()
2562            .llm(provider.clone())
2563            .tools(ToolRegistry::default())
2564            .build()
2565            .expect("runtime build");
2566        let runtime_arc = Arc::new(tokio::sync::Mutex::new(runtime));
2567        let session = SessionState {
2568            id: session_id.to_string(),
2569            created_at: "2026-01-01T00:00:00Z".to_string(),
2570            title: Some("old".into()),
2571            runtime: runtime_arc.clone(),
2572            plan_approval_gate: Default::default(),
2573            interrupt_token: Arc::new(tokio::sync::Mutex::new(None)),
2574            non_system_message_count: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
2575            last_active_ms: Arc::new(AtomicU64::new(0)),
2576            prompt_tokens: Arc::new(AtomicU64::new(0)),
2577            completion_tokens: Arc::new(AtomicU64::new(0)),
2578        };
2579        let sessions: HashMap<String, SessionState> = [(session_id.to_string(), session)].into();
2580        let state = Arc::new(AppState {
2581            tools: vec![],
2582            tool_registry: ToolRegistry::default(),
2583            config,
2584            provider,
2585            sessions: Arc::new(tokio::sync::RwLock::new(sessions)),
2586            event_channels: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
2587            metrics: Arc::new(crate::http::Metrics::default()),
2588            slash_commands: Arc::new(vec![]),
2589            session_ttl_secs: 3600,
2590            run_semaphore: Arc::new(Semaphore::new(8)),
2591            rate_limiter: crate::http::RateLimiter::new(10, 1.0),
2592            skills: vec![],
2593        });
2594        (state, runtime_arc)
2595    }
2596
2597    #[tokio::test]
2598    async fn get_session_status_idle_vs_plan_pending() {
2599        let sid = "test-plan-status";
2600        let (state, _) = test_app_state_with_session(sid);
2601
2602        let idle = match get_session(State(state.clone()), Path(sid.to_string())).await {
2603            Ok(Json(v)) => v,
2604            Err(_) => panic!("get_session idle"),
2605        };
2606        assert_eq!(idle.status, "idle");
2607        assert!(idle.pending_plan.is_none());
2608
2609        // Set pending plan via the gate.
2610        {
2611            let sessions = state.sessions.read().await;
2612            let session = sessions.get(sid).unwrap();
2613            *session.plan_approval_gate.pending_plan.write().unwrap() = Some("do the thing".into());
2614        }
2615        let pending = match get_session(State(state), Path(sid.to_string())).await {
2616            Ok(Json(v)) => v,
2617            Err(_) => panic!("get_session pending"),
2618        };
2619        assert_eq!(pending.status, "plan_pending_approval");
2620        assert_eq!(pending.pending_plan.as_deref(), Some("do the thing"));
2621    }
2622
2623    #[tokio::test]
2624    async fn get_session_busy_runtime_returns_empty_messages() {
2625        let sid = "test-busy-get";
2626        let (state, runtime_arc) = test_app_state_with_session(sid);
2627        let _guard = runtime_arc.lock().await;
2628        let detail = match get_session(State(state), Path(sid.to_string())).await {
2629            Ok(Json(v)) => v,
2630            Err(_) => panic!("busy get_session must still 200"),
2631        };
2632        assert!(
2633            detail.messages.is_empty(),
2634            "busy runtime must fall back to empty messages"
2635        );
2636        assert_eq!(detail.status, "idle");
2637    }
2638
2639    #[tokio::test]
2640    async fn patch_session_empty_title_clears() {
2641        let sid = "test-patch-title";
2642        let (state, _) = test_app_state_with_session(sid);
2643
2644        let cleared = match patch_session(
2645            State(state.clone()),
2646            Path(sid.to_string()),
2647            Json(PatchSessionRequest {
2648                title: Some("".into()),
2649            }),
2650        )
2651        .await
2652        {
2653            Ok(Json(v)) => v,
2654            Err(_) => panic!("patch empty title"),
2655        };
2656        assert!(cleared.title.is_none(), "empty title must clear to None");
2657
2658        let set = match patch_session(
2659            State(state.clone()),
2660            Path(sid.to_string()),
2661            Json(PatchSessionRequest {
2662                title: Some("new".into()),
2663            }),
2664        )
2665        .await
2666        {
2667            Ok(Json(v)) => v,
2668            Err(_) => panic!("patch new title"),
2669        };
2670        assert_eq!(set.title.as_deref(), Some("new"));
2671
2672        // Omitting title must leave existing value unchanged.
2673        let keep = match patch_session(
2674            State(state),
2675            Path(sid.to_string()),
2676            Json(PatchSessionRequest { title: None }),
2677        )
2678        .await
2679        {
2680            Ok(Json(v)) => v,
2681            Err(_) => panic!("patch omit title"),
2682        };
2683        assert_eq!(keep.title.as_deref(), Some("new"));
2684    }
2685
2686    /// Goal-292: metrics_handler output includes sessions_active and
2687    /// rate_limits_rejected.
2688    #[tokio::test]
2689    async fn metrics_handler_includes_new_fields() {
2690        use crate::http::Metrics;
2691        use crate::tools::ToolRegistry;
2692        use std::sync::atomic::AtomicU64;
2693        std::env::set_var("RECURSIVE_API_KEY", "test-key");
2694        std::env::set_var("RECURSIVE_MODEL", "test-model");
2695        let config = crate::config::Config::from_env().unwrap();
2696        let metrics = Metrics {
2697            sessions_active: AtomicU64::new(3),
2698            rate_limits_rejected: AtomicU64::new(42),
2699            ..Metrics::default()
2700        };
2701        let state = Arc::new(AppState {
2702            metrics: Arc::new(metrics),
2703            tools: vec![],
2704            tool_registry: ToolRegistry::default(),
2705            config,
2706            provider: Arc::new(crate::llm::MockProvider::new(vec![])),
2707            sessions: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
2708            event_channels: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
2709            slash_commands: Arc::new(vec![]),
2710            session_ttl_secs: 3600,
2711            run_semaphore: Arc::new(tokio::sync::Semaphore::new(8)),
2712            rate_limiter: crate::http::RateLimiter::new(10, 1.0),
2713            skills: vec![],
2714        });
2715        let output = metrics_handler(State(state)).await;
2716        assert!(
2717            output.contains("recursive_sessions_active 3"),
2718            "output should contain sessions_active: {output}"
2719        );
2720        assert!(
2721            output.contains("recursive_rate_limits_rejected_total 42"),
2722            "output should contain rate_limits_rejected_total: {output}"
2723        );
2724    }
2725
2726    /// Goal-292: sessions_active increments on create_session and
2727    /// decrements on delete_session.
2728    #[tokio::test]
2729    async fn sessions_active_tracks_session_lifecycle() {
2730        use crate::tools::ToolRegistry;
2731        use tower::ServiceExt;
2732        std::env::set_var("RECURSIVE_API_KEY", "test-key");
2733        std::env::set_var("RECURSIVE_MODEL", "test-model");
2734        std::env::set_var("RECURSIVE_HTTP_AUTH_INSECURE_OK", "1");
2735        let config = crate::config::Config::from_env().unwrap();
2736        let provider = Arc::new(crate::llm::MockProvider::new(vec![]));
2737        let metrics = Arc::new(crate::http::Metrics::default());
2738
2739        let state = AppState {
2740            tools: vec![],
2741            tool_registry: ToolRegistry::default(),
2742            config,
2743            provider,
2744            sessions: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
2745            event_channels: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
2746            metrics: metrics.clone(),
2747            slash_commands: Arc::new(vec![]),
2748            session_ttl_secs: 3600,
2749            run_semaphore: Arc::new(tokio::sync::Semaphore::new(8)),
2750            rate_limiter: crate::http::RateLimiter::new(100, 1.0),
2751            skills: vec![],
2752        };
2753
2754        let auth = crate::http::auth::AuthConfig::default();
2755        let limiter = state.rate_limiter.clone();
2756        let app = crate::http::build_router_with_auth_and_rate_limit(state, auth, limiter);
2757
2758        // Initially sessions_active is 0.
2759        assert_eq!(
2760            metrics.sessions_active.load(Ordering::Relaxed),
2761            0,
2762            "sessions_active should start at 0"
2763        );
2764
2765        // Create a session.
2766        let resp = app
2767            .clone()
2768            .oneshot(
2769                axum::extract::Request::builder()
2770                    .method("POST")
2771                    .uri("/sessions")
2772                    .header("content-type", "application/json")
2773                    .body(axum::body::Body::from(r#"{"session_name":"test"}"#))
2774                    .unwrap(),
2775            )
2776            .await
2777            .unwrap();
2778        assert_eq!(
2779            resp.status(),
2780            StatusCode::CREATED,
2781            "expected 201 Created from POST /sessions"
2782        );
2783        let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
2784        let created: CreateSessionResponse =
2785            serde_json::from_slice(&body).expect("valid CreateSessionResponse");
2786        let session_id = created.id;
2787
2788        // sessions_active should now be 1.
2789        assert_eq!(
2790            metrics.sessions_active.load(Ordering::Relaxed),
2791            1,
2792            "sessions_active should increment to 1 after create"
2793        );
2794
2795        // Delete the session.
2796        let resp = app
2797            .clone()
2798            .oneshot(
2799                axum::extract::Request::builder()
2800                    .method("DELETE")
2801                    .uri(format!("/sessions/{session_id}"))
2802                    .body(axum::body::Body::empty())
2803                    .unwrap(),
2804            )
2805            .await
2806            .unwrap();
2807        assert_eq!(
2808            resp.status(),
2809            StatusCode::NO_CONTENT,
2810            "expected 204 No Content from DELETE /sessions/:id"
2811        );
2812
2813        // sessions_active should be back to 0.
2814        assert_eq!(
2815            metrics.sessions_active.load(Ordering::Relaxed),
2816            0,
2817            "sessions_active should decrement to 0 after delete"
2818        );
2819    }
2820
2821    // ── G298: OpenAPI spec sync ───────────────────────────────────────
2822
2823    /// Verify that `build_openapi_spec()` correctly describes the
2824    /// `SessionDetailResponse` schema with all the fields added in
2825    /// G293, G294, G295, G296, etc.
2826    #[test]
2827    fn openapi_session_detail_has_complete_schema() {
2828        let spec = super::build_openapi_spec();
2829        let props = &spec["components"]["schemas"]["SessionDetailResponse"]["properties"];
2830
2831        // Fields from the original (G-pre) spec.
2832        assert!(props.get("id").is_some(), "id missing");
2833        assert!(props.get("created_at").is_some(), "created_at missing");
2834        assert!(props.get("messages").is_some(), "messages missing");
2835
2836        // Fields added by G293-G296 that the goal explicitly requires.
2837        assert!(
2838            props.get("prompt_tokens").is_some(),
2839            "prompt_tokens missing"
2840        );
2841        assert!(
2842            props.get("completion_tokens").is_some(),
2843            "completion_tokens missing"
2844        );
2845        assert!(props.get("status").is_some(), "status missing");
2846        assert!(props.get("todos").is_some(), "todos missing");
2847        assert!(props.get("goal").is_some(), "goal missing");
2848
2849        // Remaining fields: title, pending_plan, first_prompt, last_prompt.
2850        assert!(props.get("title").is_some(), "title missing");
2851        assert!(props.get("pending_plan").is_some(), "pending_plan missing");
2852        assert!(props.get("first_prompt").is_some(), "first_prompt missing");
2853        assert!(props.get("last_prompt").is_some(), "last_prompt missing");
2854
2855        // Verify we have at least 10 properties total.
2856        let obj = props.as_object().expect("properties is an object");
2857        assert!(
2858            obj.len() >= 10,
2859            "SessionDetailResponse should have ≥10 properties, got {}",
2860            obj.len()
2861        );
2862    }
2863
2864    /// Verify `SessionInfo` schema includes `message_count` and `title`.
2865    #[test]
2866    fn openapi_session_info_has_message_count_and_title() {
2867        let spec = super::build_openapi_spec();
2868        let props = &spec["components"]["schemas"]["SessionInfo"]["properties"];
2869
2870        assert!(props.get("id").is_some(), "id missing");
2871        assert!(props.get("created_at").is_some(), "created_at missing");
2872        assert!(
2873            props.get("message_count").is_some(),
2874            "message_count missing"
2875        );
2876        assert!(props.get("title").is_some(), "title missing");
2877    }
2878
2879    /// Verify the `/metrics` path exists and mentions the two G292
2880    /// metrics in its description.
2881    #[test]
2882    fn openapi_metrics_path_documents_new_metrics() {
2883        let spec = super::build_openapi_spec();
2884        let description = spec["paths"]["/metrics"]["get"]["description"]
2885            .as_str()
2886            .expect("metrics path description is a string");
2887
2888        assert!(
2889            description.contains("recursive_sessions_active"),
2890            "metrics description should mention recursive_sessions_active: {description}"
2891        );
2892        assert!(
2893            description.contains("recursive_rate_limits_rejected_total"),
2894            "metrics description should mention recursive_rate_limits_rejected_total: {description}"
2895        );
2896    }
2897
2898    // ── Goal-303: sort GET /sessions results by created_at ───────────
2899
2900    /// Verify that sorting SessionInfo by `created_at` yields
2901    /// chronological order (oldest first).
2902    #[test]
2903    fn list_sessions_sort_is_chronological() {
2904        let mut infos = [
2905            SessionInfo {
2906                id: "c".into(),
2907                created_at: "2026-01-03T00:00:00Z".into(),
2908                message_count: 0,
2909                title: None,
2910            },
2911            SessionInfo {
2912                id: "a".into(),
2913                created_at: "2026-01-01T00:00:00Z".into(),
2914                message_count: 0,
2915                title: None,
2916            },
2917            SessionInfo {
2918                id: "b".into(),
2919                created_at: "2026-01-02T00:00:00Z".into(),
2920                message_count: 0,
2921                title: None,
2922            },
2923        ];
2924        infos.sort_by(|a, b| a.created_at.cmp(&b.created_at).then(a.id.cmp(&b.id)));
2925        assert_eq!(infos[0].id, "a");
2926        assert_eq!(infos[1].id, "b");
2927        assert_eq!(infos[2].id, "c");
2928    }
2929
2930    /// Verify that sessions created in the same second are tie-broken
2931    /// by `id` so the sort remains fully deterministic.
2932    #[test]
2933    fn list_sessions_same_second_tiebreak_by_id() {
2934        let mut infos = [
2935            SessionInfo {
2936                id: "z".into(),
2937                created_at: "2026-01-01T00:00:00Z".into(),
2938                message_count: 0,
2939                title: None,
2940            },
2941            SessionInfo {
2942                id: "a".into(),
2943                created_at: "2026-01-01T00:00:00Z".into(),
2944                message_count: 0,
2945                title: None,
2946            },
2947        ];
2948        infos.sort_by(|a, b| a.created_at.cmp(&b.created_at).then(a.id.cmp(&b.id)));
2949        assert_eq!(infos[0].id, "a");
2950        assert_eq!(infos[1].id, "z");
2951    }
2952
2953    // ── Goal-312: skill_index injection into system prompt ──────────
2954
2955    /// Verify the skill catalog is produced as a `system-reminder` and is
2956    /// NOT inlined into the static system prompt (it ships per-turn via
2957    /// `skill_reminder`, keeping the `system` field cacheable).
2958    #[test]
2959    fn skill_reminder_produced_and_not_inlined() {
2960        let skills = vec![crate::skills::Skill {
2961            name: "rust-patch-discipline".to_string(),
2962            description: "V4A patch format rules".to_string(),
2963            path: std::path::PathBuf::from("/tmp/skills/rust-patch-discipline/SKILL.md"),
2964            mode: crate::skills::SkillMode::Manual,
2965            triggers: vec![],
2966            hint: String::new(),
2967            depends_on: vec![],
2968            refs: vec![],
2969            params: vec![],
2970            scripts: vec![],
2971            sections: vec![],
2972            globs: None,
2973        }];
2974
2975        let idx = crate::skills::skill_index(&skills);
2976        assert!(!idx.is_empty(), "skill_index should not be empty");
2977        assert!(
2978            idx.contains("Available skills"),
2979            "skill_index should contain header"
2980        );
2981        assert!(
2982            idx.contains("rust-patch-discipline"),
2983            "skill_index should list skill names"
2984        );
2985
2986        // The catalog ships as a system-reminder block.
2987        let reminder = crate::skills::skill_reminder(&skills);
2988        assert!(reminder.contains("<system-reminder>"), "reminder wrapper");
2989        assert!(
2990            reminder.contains("Available skills"),
2991            "reminder should contain header"
2992        );
2993        assert!(
2994            reminder.contains("rust-patch-discipline"),
2995            "reminder should contain skill name"
2996        );
2997
2998        // The static system prompt must NOT inline it.
2999        let base_prompt = "You are a helpful agent.";
3000        let assembled =
3001            crate::assemble_system_prompt(base_prompt, std::path::Path::new(""), &skills, false)
3002                .into_full();
3003        assert!(
3004            !assembled.contains("Available skills"),
3005            "system prompt must not inline skill catalog: {assembled}"
3006        );
3007    }
3008
3009    /// skill_index returns empty string when no skills are present,
3010    /// so injection is a no-op (no spurious newlines).
3011    #[test]
3012    fn system_prompt_unchanged_when_no_skills() {
3013        let skills: Vec<crate::skills::Skill> = vec![];
3014        let idx = crate::skills::skill_index(&skills);
3015        assert!(idx.is_empty(), "empty skills should produce empty index");
3016
3017        let base_prompt = "You are a helpful agent.";
3018        let mut sp = base_prompt.to_string();
3019        if !idx.is_empty() {
3020            sp.push('\n');
3021            sp.push_str(&idx);
3022        }
3023        // The prompt should be unchanged.
3024        assert_eq!(sp, base_prompt);
3025    }
3026
3027    // ── parse_permission_mode ───────────────────────────────────────────────
3028
3029    #[test]
3030    fn parse_permission_mode_all_variants() {
3031        assert_eq!(parse_permission_mode("auto", false), PermissionMode::Auto);
3032        assert_eq!(parse_permission_mode("AUTO", true), PermissionMode::Auto);
3033        assert_eq!(
3034            parse_permission_mode("strict", false),
3035            PermissionMode::Strict
3036        );
3037        assert_eq!(
3038            parse_permission_mode("bypass", true),
3039            PermissionMode::BypassPermissions
3040        );
3041        assert_eq!(
3042            parse_permission_mode("bypass_permissions", true),
3043            PermissionMode::BypassPermissions
3044        );
3045        // Bypass rejected when allow_bypass=false → Default (kills match-guard mutant).
3046        assert_eq!(
3047            parse_permission_mode("bypass", false),
3048            PermissionMode::Default
3049        );
3050        assert_eq!(
3051            parse_permission_mode("bypass_permissions", false),
3052            PermissionMode::Default
3053        );
3054        assert_eq!(
3055            parse_permission_mode("default", true),
3056            PermissionMode::Default
3057        );
3058        assert_eq!(
3059            parse_permission_mode("unknown", true),
3060            PermissionMode::Default
3061        );
3062    }
3063
3064    // ── map_agent_event / sse_message_from_canonical ────────────────────────
3065
3066    #[test]
3067    fn map_agent_event_tool_result_success_flag() {
3068        let ok = AgentEvent::ToolResult {
3069            id: "tc-1".into(),
3070            name: "Bash".into(),
3071            output: "ok".into(),
3072            step: 0,
3073            is_error: false,
3074        };
3075        let err = AgentEvent::ToolResult {
3076            id: "tc-2".into(),
3077            name: "Bash".into(),
3078            output: "fail".into(),
3079            step: 0,
3080            is_error: true,
3081        };
3082        match map_agent_event(&ok) {
3083            Some(SseEvent::ToolResult { success, .. }) => assert!(success),
3084            other => panic!("expected ToolResult success=true, got {other:?}"),
3085        }
3086        match map_agent_event(&err) {
3087            Some(SseEvent::ToolResult { success, .. }) => assert!(!success),
3088            other => panic!("expected ToolResult success=false, got {other:?}"),
3089        }
3090    }
3091
3092    #[test]
3093    fn map_agent_event_suppresses_non_sse_variants() {
3094        // Latency / Usage / AssistantText have no SSE equivalent.
3095        assert!(map_agent_event(&AgentEvent::Latency { step: 0, llm_ms: 1 }).is_none());
3096        assert!(map_agent_event(&AgentEvent::Usage {
3097            input_tokens: 1,
3098            output_tokens: 1,
3099            cache_hit_tokens: 0,
3100            cache_miss_tokens: 0,
3101            step: 0,
3102        })
3103        .is_none());
3104        assert!(map_agent_event(&AgentEvent::AssistantText {
3105            text: "hi".into(),
3106            step: 0
3107        })
3108        .is_none());
3109    }
3110
3111    #[test]
3112    fn map_agent_event_forwards_goal_loop_events() {
3113        // kills delete-match-arm on GoalContinuing / GoalAchieved
3114        match map_agent_event(&AgentEvent::GoalContinuing {
3115            reason: "still working".into(),
3116            turns: 3,
3117        }) {
3118            Some(SseEvent::GoalContinuing { reason, turns }) => {
3119                assert_eq!(reason, "still working");
3120                assert_eq!(turns, 3);
3121            }
3122            other => panic!("expected GoalContinuing, got {other:?}"),
3123        }
3124        match map_agent_event(&AgentEvent::GoalAchieved {
3125            condition: "tests pass".into(),
3126            turns: 5,
3127        }) {
3128            Some(SseEvent::GoalAchieved { condition, turns }) => {
3129                assert_eq!(condition, "tests pass");
3130                assert_eq!(turns, 5);
3131            }
3132            other => panic!("expected GoalAchieved, got {other:?}"),
3133        }
3134    }
3135
3136    #[test]
3137    fn map_agent_event_forwards_core_sse_arms() {
3138        // kills delete-match-arm on PartialToken / ToolCall / TurnFinished / PlanProposed
3139        match map_agent_event(&AgentEvent::PartialToken {
3140            text: "tok".into(),
3141            step: 2,
3142        }) {
3143            Some(SseEvent::PartialMessage { text, step }) => {
3144                assert_eq!(text, "tok");
3145                assert_eq!(step, 2);
3146            }
3147            other => panic!("expected PartialMessage, got {other:?}"),
3148        }
3149        match map_agent_event(&AgentEvent::ToolCall {
3150            name: "Bash".into(),
3151            id: "tc-1".into(),
3152            arguments: "{}".into(),
3153            step: 1,
3154        }) {
3155            Some(SseEvent::ToolCall { name, step }) => {
3156                assert_eq!(name, "Bash");
3157                assert_eq!(step, 1);
3158            }
3159            other => panic!("expected ToolCall, got {other:?}"),
3160        }
3161        match map_agent_event(&AgentEvent::TurnFinished {
3162            reason: "no_more_tool_calls".into(),
3163            steps: 4,
3164        }) {
3165            Some(SseEvent::Done {
3166                finish_reason,
3167                total_steps,
3168            }) => {
3169                assert_eq!(finish_reason, "no_more_tool_calls");
3170                assert_eq!(total_steps, 4);
3171            }
3172            other => panic!("expected Done, got {other:?}"),
3173        }
3174        match map_agent_event(&AgentEvent::PlanProposed {
3175            plan_text: "do X".into(),
3176            tool_calls: vec![],
3177        }) {
3178            Some(SseEvent::PlanProposed { plan }) => assert_eq!(plan, "do X"),
3179            other => panic!("expected PlanProposed, got {other:?}"),
3180        }
3181    }
3182
3183    #[test]
3184    fn sse_message_from_canonical_filters_system_tool_and_empty() {
3185        assert!(
3186            sse_message_from_canonical(&crate::message::Message::system("seed")).is_none(),
3187            "system messages must be filtered"
3188        );
3189        let mut tool = crate::message::Message::user("unused");
3190        tool.role = crate::message::Role::Tool;
3191        tool.tool_call_id = Some("tc-1".into());
3192        assert!(
3193            sse_message_from_canonical(&tool).is_none(),
3194            "tool messages must be filtered"
3195        );
3196        assert!(
3197            sse_message_from_canonical(&crate::message::Message::assistant("")).is_none(),
3198            "empty assistant with no tool_calls must be None"
3199        );
3200        match sse_message_from_canonical(&crate::message::Message::assistant("hello")) {
3201            Some(SseEvent::Message { role, content }) => {
3202                assert_eq!(role, "assistant");
3203                assert!(!content.is_empty());
3204            }
3205            other => panic!("expected Message event, got {other:?}"),
3206        }
3207        match sse_message_from_canonical(&crate::message::Message::user("hi")) {
3208            Some(SseEvent::Message { role, .. }) => assert_eq!(role, "user"),
3209            other => panic!("expected user Message, got {other:?}"),
3210        }
3211    }
3212
3213    #[test]
3214    fn sse_message_from_canonical_emits_tool_use_without_text() {
3215        // kills early-empty return before tool_calls loop / ToolUse arm delete
3216        let msg = crate::message::Message::assistant_with_tool_calls(
3217            "",
3218            vec![crate::llm::ToolCall {
3219                id: "tc-9".into(),
3220                name: "Read".into(),
3221                arguments: serde_json::json!({"path":"a.rs"}),
3222            }],
3223        );
3224        match sse_message_from_canonical(&msg) {
3225            Some(SseEvent::Message { role, content }) => {
3226                assert_eq!(role, "assistant");
3227                assert!(
3228                    content.iter().any(|b| matches!(
3229                        b,
3230                        SseContentBlock::ToolUse { id, name, .. }
3231                            if id == "tc-9" && name == "Read"
3232                    )),
3233                    "expected ToolUse block, got {content:?}"
3234                );
3235            }
3236            other => panic!("expected Message with ToolUse, got {other:?}"),
3237        }
3238    }
3239
3240    #[test]
3241    fn agui_events_for_partial_token_then_tool_call_closes_stream() {
3242        use agui_protocol as ag;
3243        // Use AguiConverter directly so open_message_id state spans events.
3244        let mut conv = AguiConverter::new();
3245        let t1 = conv.convert(&AgentEvent::PartialToken {
3246            text: "a".into(),
3247            step: 0,
3248        });
3249        assert_eq!(t1.len(), 2, "Start+Content expected: {t1:?}");
3250        assert!(matches!(t1[0], ag::Event::TextMessageStart(_)));
3251        assert!(matches!(t1[1], ag::Event::TextMessageContent(_)));
3252        let t2 = conv.convert(&AgentEvent::PartialToken {
3253            text: "b".into(),
3254            step: 0,
3255        });
3256        assert_eq!(t2.len(), 1, "Content-only expected: {t2:?}");
3257        assert!(matches!(t2[0], ag::Event::TextMessageContent(_)));
3258        let t3 = conv.convert(&AgentEvent::ToolCall {
3259            id: "tc-1".into(),
3260            name: "Bash".into(),
3261            arguments: "{}".into(),
3262            step: 0,
3263        });
3264        assert!(
3265            t3.iter().any(|e| matches!(e, ag::Event::TextMessageEnd(_))),
3266            "ToolCall must close open text stream: {t3:?}"
3267        );
3268        assert!(
3269            t3.iter().any(|e| matches!(e, ag::Event::ToolCallStart(_))),
3270            "ToolCall must emit ToolCallStart: {t3:?}"
3271        );
3272        assert!(
3273            t3.iter().any(|e| matches!(e, ag::Event::ToolCallArgs(_))),
3274            "ToolCall must emit ToolCallArgs: {t3:?}"
3275        );
3276        assert!(
3277            t3.iter().any(|e| matches!(e, ag::Event::ToolCallEnd(_))),
3278            "ToolCall must emit ToolCallEnd: {t3:?}"
3279        );
3280    }
3281
3282    // ── format_timestamp ────────────────────────────────────────────────────
3283
3284    #[test]
3285    fn format_timestamp_unix_epoch_is_1970() {
3286        let ts = format_timestamp(SystemTime::UNIX_EPOCH);
3287        assert_eq!(
3288            &ts[..10],
3289            "1970-01-01",
3290            "epoch date must be 1970-01-01; got {ts}"
3291        );
3292        assert_eq!(
3293            &ts[11..19],
3294            "00:00:00",
3295            "epoch time must be 00:00:00; got {ts}"
3296        );
3297        assert!(ts.ends_with('Z'), "must end with Z; got {ts}");
3298    }
3299
3300    #[test]
3301    fn format_timestamp_known_date() {
3302        // 2026-07-06T00:00:00Z = 20640 days * 86400 sec = 1_783_296_000 seconds
3303        let secs = 1_783_296_000u64;
3304        let t = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs);
3305        let ts = format_timestamp(t);
3306        assert_eq!(&ts[..10], "2026-07-06", "expected 2026-07-06; got {ts}");
3307        assert_eq!(&ts[11..19], "00:00:00", "time part must be zero; got {ts}");
3308    }
3309
3310    #[test]
3311    fn format_timestamp_time_parts_in_range() {
3312        // 2026-07-06T13:45:30Z
3313        let secs: u64 = 1_783_296_000 + 13 * 3600 + 45 * 60 + 30;
3314        let t = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs);
3315        let ts = format_timestamp(t);
3316        assert_eq!(&ts[11..13], "13", "hours mismatch; got {ts}");
3317        assert_eq!(&ts[14..16], "45", "minutes mismatch; got {ts}");
3318        assert_eq!(&ts[17..19], "30", "seconds mismatch; got {ts}");
3319    }
3320
3321    // ── sanitize_thread_id_for_session ──────────────────────────────────────
3322
3323    #[test]
3324    fn sanitize_thread_id_valid_passthrough() {
3325        assert_eq!(sanitize_thread_id_for_session("abc-123"), "abc-123");
3326        assert_eq!(sanitize_thread_id_for_session("foo_bar.baz"), "foo_bar.baz");
3327    }
3328
3329    #[test]
3330    fn sanitize_thread_id_replaces_special_chars() {
3331        let out = sanitize_thread_id_for_session("a/b:c");
3332        assert!(!out.contains('/'), "slash must be replaced");
3333        assert!(!out.contains(':'), "colon must be replaced");
3334    }
3335
3336    #[test]
3337    fn sanitize_thread_id_leading_dot_replaced() {
3338        let out = sanitize_thread_id_for_session(".hidden");
3339        assert!(
3340            !out.starts_with('.'),
3341            "leading dot must be replaced; got {out}"
3342        );
3343    }
3344
3345    #[test]
3346    fn sanitize_thread_id_double_dot_collapsed() {
3347        let out = sanitize_thread_id_for_session("a..b");
3348        assert!(
3349            !out.contains(".."),
3350            "double dot must be collapsed; got {out}"
3351        );
3352    }
3353
3354    #[test]
3355    fn sanitize_thread_id_empty_becomes_default() {
3356        assert_eq!(sanitize_thread_id_for_session(""), "default");
3357    }
3358}