Skip to main content

recursive/http/
handlers.rs

1//! HTTP handler functions for the agent API.
2
3use axum::{
4    extract::{Path, State},
5    http::StatusCode,
6    response::sse::{Event, Sse},
7    Json,
8};
9use std::collections::HashMap;
10use std::convert::Infallible;
11use std::sync::atomic::{AtomicU64, Ordering};
12use std::sync::Arc;
13use std::time::SystemTime;
14use tokio::sync::broadcast;
15use tokio_stream::{wrappers::BroadcastStream, StreamExt};
16
17use crate::event::{AgentEvent, ChannelSink, NullSink};
18use crate::runtime::AgentRuntimeBuilder;
19
20use super::{
21    build_openapi_spec, AppState, CreateSessionRequest, CreateSessionResponse, ErrorResponse,
22    ListSessionsQuery, RunRequest, RunResponse, SessionDetailResponse, SessionInfo,
23    SessionMessageRequest, SessionMessageResponse, SessionState, SetGoalRequest, SlashCommandInfo,
24    SseContentBlock, SseEvent, ToolInfo, UsageInfo,
25};
26
27pub(super) async fn health() -> &'static str {
28    "ok"
29}
30
31pub(super) async fn openapi_spec() -> Json<serde_json::Value> {
32    Json(build_openapi_spec())
33}
34
35pub(super) async fn list_tools(State(state): State<Arc<AppState>>) -> Json<Vec<ToolInfo>> {
36    Json(state.tools.clone())
37}
38
39pub(super) async fn run_agent(
40    State(state): State<Arc<AppState>>,
41    Json(body): Json<RunRequest>,
42) -> Result<Json<RunResponse>, (StatusCode, Json<ErrorResponse>)> {
43    // Validate: goal must not be empty
44    if body.goal.trim().is_empty() {
45        return Err((
46            StatusCode::BAD_REQUEST,
47            Json(ErrorResponse {
48                status: "error".into(),
49                error: "missing or empty 'goal' field".into(),
50            }),
51        ));
52    }
53
54    let max_steps = body.max_steps.unwrap_or(state.config.max_steps as u32) as usize;
55    let system_prompt = body
56        .system_prompt
57        .unwrap_or_else(|| state.config.system_prompt.clone());
58
59    let mut runtime = AgentRuntimeBuilder::new()
60        .llm(state.provider.clone())
61        .tools(state.tool_registry.clone())
62        .system_prompt(system_prompt)
63        .max_steps(max_steps)
64        .build()
65        .map_err(|e| {
66            (
67                StatusCode::INTERNAL_SERVER_ERROR,
68                Json(ErrorResponse {
69                    status: "error".into(),
70                    error: format!("failed to build runtime: {e}"),
71                }),
72            )
73        })?;
74
75    let outcome = runtime.run(&body.goal).await.map_err(|e| {
76        state
77            .metrics
78            .agent_runs_total
79            .fetch_add(1, Ordering::Relaxed);
80        state
81            .metrics
82            .agent_runs_failed
83            .fetch_add(1, Ordering::Relaxed);
84        (
85            StatusCode::INTERNAL_SERVER_ERROR,
86            Json(ErrorResponse {
87                status: "error".into(),
88                error: format!("agent run failed: {e}"),
89            }),
90        )
91    })?;
92
93    // Increment metrics
94    state
95        .metrics
96        .agent_runs_total
97        .fetch_add(1, Ordering::Relaxed);
98    state
99        .metrics
100        .agent_runs_success
101        .fetch_add(1, Ordering::Relaxed);
102    state
103        .metrics
104        .agent_steps_total
105        .fetch_add(outcome.steps as u64, Ordering::Relaxed);
106    state
107        .metrics
108        .tokens_prompt_total
109        .fetch_add(outcome.total_usage.prompt_tokens as u64, Ordering::Relaxed);
110    state.metrics.tokens_completion_total.fetch_add(
111        outcome.total_usage.completion_tokens as u64,
112        Ordering::Relaxed,
113    );
114
115    // Serialize transcript messages to JSON values
116    let messages: Vec<serde_json::Value> = runtime
117        .transcript()
118        .iter()
119        .filter_map(|msg| serde_json::to_value(msg).ok())
120        .collect();
121
122    let finish_reason = format!("{:?}", outcome.finish_reason);
123
124    Ok(Json(RunResponse {
125        status: "success".into(),
126        finish_reason,
127        messages,
128        usage: UsageInfo {
129            total_steps: outcome.steps as u32,
130            total_tokens: outcome.total_usage.total_tokens as u64,
131        },
132    }))
133}
134
135// ── Session endpoints ──────────────────────────────────────────────────────
136
137/// Generate a session ID using blake3 hash of timestamp + counter.
138fn generate_session_id() -> String {
139    static COUNTER: AtomicU64 = AtomicU64::new(0);
140
141    let count = COUNTER.fetch_add(1, Ordering::Relaxed);
142    let now = SystemTime::now()
143        .duration_since(SystemTime::UNIX_EPOCH)
144        .unwrap_or_default();
145    let input = format!("{}-{}", now.as_nanos(), count);
146    let hash = blake3::hash(input.as_bytes());
147    // Use first 16 hex chars for a short-ish but unique ID
148    hash.to_hex()[..16].to_string()
149}
150
151/// Format a SystemTime as a basic ISO-8601 string (without chrono).
152fn format_timestamp(t: SystemTime) -> String {
153    let dur = t.duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default();
154    let secs = dur.as_secs();
155    // Basic formatting: seconds since epoch as a simple numeric timestamp
156    // For a more human-readable format we do manual UTC conversion
157    let days = secs / 86400;
158    let remaining = secs % 86400;
159    let hours = remaining / 3600;
160    let minutes = (remaining % 3600) / 60;
161    let seconds = remaining % 60;
162
163    // Days since 1970-01-01
164    let (year, month, day) = days_to_ymd(days);
165    format!(
166        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
167        year, month, day, hours, minutes, seconds
168    )
169}
170
171/// Convert days since epoch to (year, month, day).
172fn days_to_ymd(days: u64) -> (u64, u64, u64) {
173    // Simplified civil calendar calculation
174    let mut y = 1970;
175    let mut remaining = days;
176    loop {
177        let days_in_year = if is_leap(y) { 366 } else { 365 };
178        if remaining < days_in_year {
179            break;
180        }
181        remaining -= days_in_year;
182        y += 1;
183    }
184    let months_days: [u64; 12] = if is_leap(y) {
185        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
186    } else {
187        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
188    };
189    let mut m = 1;
190    for &md in &months_days {
191        if remaining < md {
192            break;
193        }
194        remaining -= md;
195        m += 1;
196    }
197    (y, m, remaining + 1)
198}
199
200fn is_leap(y: u64) -> bool {
201    (y % 4 == 0 && y % 100 != 0) || y % 400 == 0
202}
203
204/// POST /sessions — create a new session.
205pub(super) async fn create_session(
206    State(state): State<Arc<AppState>>,
207    Json(body): Json<CreateSessionRequest>,
208) -> Result<(StatusCode, Json<CreateSessionResponse>), (StatusCode, Json<ErrorResponse>)> {
209    let id = generate_session_id();
210    let created_at = format_timestamp(SystemTime::now());
211    let system_prompt = body
212        .system_prompt
213        .unwrap_or_else(|| state.config.system_prompt.clone());
214
215    let mut runtime = AgentRuntimeBuilder::new()
216        .llm(state.provider.clone())
217        .tools(state.tool_registry.clone())
218        .system_prompt(system_prompt)
219        .max_steps(state.config.max_steps)
220        .build()
221        .map_err(|e| {
222            (
223                StatusCode::INTERNAL_SERVER_ERROR,
224                Json(ErrorResponse {
225                    status: "error".into(),
226                    error: format!("failed to build session runtime: {e}"),
227                }),
228            )
229        })?;
230
231    // Register the session ID so all turns emit tracing spans with session_id
232    // and transcript is auto-saved to the storage backend after each turn.
233    runtime.set_session_id(&id);
234
235    // Extract the gate before moving runtime into the Mutex so HTTP handlers
236    // can approve/reject without acquiring the per-session runtime lock.
237    let plan_approval_gate = runtime.plan_approval_gate();
238
239    let session = SessionState {
240        id: id.clone(),
241        created_at: created_at.clone(),
242        title: None,
243        runtime: Arc::new(tokio::sync::Mutex::new(runtime)),
244        plan_approval_gate,
245        interrupt_token: Arc::new(tokio::sync::Mutex::new(None)),
246    };
247
248    state.sessions.write().await.insert(id.clone(), session);
249
250    Ok((
251        StatusCode::CREATED,
252        Json(CreateSessionResponse { id, created_at }),
253    ))
254}
255
256/// GET /sessions — list all sessions, with optional `limit` and `offset` pagination.
257///
258/// Example: `GET /sessions?limit=10&offset=20`
259pub(super) async fn list_sessions(
260    State(state): State<Arc<AppState>>,
261    axum::extract::Query(params): axum::extract::Query<ListSessionsQuery>,
262) -> Json<Vec<SessionInfo>> {
263    let sessions = state.sessions.read().await;
264    let mut infos = Vec::with_capacity(sessions.len());
265    for s in sessions.values() {
266        // Exclude the system-prompt message from the user-visible count.
267        let message_count = s
268            .runtime
269            .lock()
270            .await
271            .transcript()
272            .iter()
273            .filter(|m| m.role != crate::message::Role::System)
274            .count();
275        infos.push(SessionInfo {
276            id: s.id.clone(),
277            created_at: s.created_at.clone(),
278            message_count,
279            title: s.title.clone(),
280        });
281    }
282    // Apply offset + limit pagination.
283    let offset = params.offset.unwrap_or(0);
284    let page: Vec<SessionInfo> = infos
285        .into_iter()
286        .skip(offset)
287        .take(params.limit.unwrap_or(usize::MAX))
288        .collect();
289    Json(page)
290}
291
292/// GET /sessions/:id — get session detail with messages.
293///
294/// Reads plan-approval status directly from the session gate (no runtime lock
295/// needed) so this endpoint stays responsive even while an agent turn is
296/// blocked awaiting plan approval.  Messages and todos fall back to empty
297/// vectors when the runtime is busy rather than deadlocking.
298pub(super) async fn get_session(
299    State(state): State<Arc<AppState>>,
300    Path(id): Path<String>,
301) -> Result<Json<SessionDetailResponse>, StatusCode> {
302    let sessions = state.sessions.read().await;
303    let session = sessions.get(&id).ok_or(StatusCode::NOT_FOUND)?;
304
305    // Read plan status without locking the runtime Mutex so callers can poll
306    // while the agent is suspended inside `exit_plan_mode`.
307    let pending_plan = session
308        .plan_approval_gate
309        .pending_plan
310        .read()
311        .ok()
312        .and_then(|g| g.clone());
313    let status = if pending_plan.is_some() {
314        "plan_pending_approval".to_string()
315    } else {
316        "idle".to_string()
317    };
318
319    // Try a non-blocking lock for messages/todos/goal; fall back to empty when busy.
320    let (messages, todos, goal) = match session.runtime.try_lock() {
321        Ok(runtime) => {
322            let msgs = runtime
323                .transcript()
324                .iter()
325                .filter_map(|msg| serde_json::to_value(msg).ok())
326                .collect();
327            let todos = runtime.current_todos();
328            let goal = runtime.current_goal();
329            (msgs, todos, goal)
330        }
331        Err(_) => (vec![], vec![], None),
332    };
333
334    // Extract first/last user prompt for display without a separate lock.
335    let (first_prompt, last_prompt) = {
336        let user_msgs: Vec<String> = messages
337            .iter()
338            .filter_map(|m| {
339                if m.get("role")?.as_str()? == "user" {
340                    m.get("content")?.as_str().map(|s| s.to_string())
341                } else {
342                    None
343                }
344            })
345            .collect();
346        let first = user_msgs.first().cloned();
347        let last = user_msgs.last().cloned();
348        (first, last)
349    };
350
351    Ok(Json(SessionDetailResponse {
352        id: session.id.clone(),
353        created_at: session.created_at.clone(),
354        title: session.title.clone(),
355        messages,
356        todos,
357        status,
358        pending_plan,
359        goal,
360        first_prompt,
361        last_prompt,
362    }))
363}
364
365/// DELETE /sessions/:id — remove a session.
366pub(super) async fn delete_session(
367    State(state): State<Arc<AppState>>,
368    Path(id): Path<String>,
369) -> StatusCode {
370    let mut sessions = state.sessions.write().await;
371    if sessions.remove(&id).is_some() {
372        StatusCode::NO_CONTENT
373    } else {
374        StatusCode::NOT_FOUND
375    }
376}
377
378// ── Session patch endpoint (rename) ──────────────────────────────────────
379
380/// Request body for `PATCH /sessions/:id` — update mutable session fields.
381#[derive(serde::Deserialize, Debug)]
382pub(super) struct PatchSessionRequest {
383    /// Optional new title for the session.
384    title: Option<String>,
385}
386
387/// PATCH /sessions/:id — update mutable session metadata.
388///
389/// Currently supports setting/clearing the `title` field.
390///
391/// Example:
392/// ```text
393/// PATCH /sessions/abc123
394/// {"title": "Fix login bug"}
395/// ```
396pub(super) async fn patch_session(
397    State(state): State<Arc<AppState>>,
398    Path(id): Path<String>,
399    Json(body): Json<PatchSessionRequest>,
400) -> Result<Json<SessionInfo>, StatusCode> {
401    let mut sessions = state.sessions.write().await;
402    let session = sessions.get_mut(&id).ok_or(StatusCode::NOT_FOUND)?;
403
404    if let Some(title) = body.title {
405        session.title = if title.is_empty() { None } else { Some(title) };
406    }
407
408    // Build response without taking the runtime lock (message_count is approximate here).
409    Ok(Json(SessionInfo {
410        id: session.id.clone(),
411        created_at: session.created_at.clone(),
412        message_count: 0, // omitted in patch response; caller can re-fetch if needed
413        title: session.title.clone(),
414    }))
415}
416
417// ── Fork session ─────────────────────────────────────────────────────────
418
419/// Response for `POST /sessions/:id/fork`.
420#[derive(serde::Serialize)]
421pub(super) struct ForkSessionResponse {
422    /// ID of the newly created forked session.
423    id: String,
424    /// Timestamp when the fork was created.
425    created_at: String,
426    /// Number of messages copied from the source session.
427    message_count: usize,
428}
429
430/// POST /sessions/:id/fork — fork a session, copying its transcript.
431///
432/// Creates a new session with the same transcript as the source session.
433/// The forked session is independent: subsequent messages do not affect the
434/// original.
435///
436/// Returns the new session's ID and metadata.
437pub(super) async fn fork_session(
438    State(state): State<Arc<AppState>>,
439    Path(id): Path<String>,
440) -> Result<(StatusCode, Json<ForkSessionResponse>), StatusCode> {
441    // Snapshot the source transcript while holding the write lock.
442    let transcript_snapshot = {
443        let sessions = state.sessions.read().await;
444        let src = sessions.get(&id).ok_or(StatusCode::NOT_FOUND)?;
445        let rt = src.runtime.try_lock().map_err(|_| StatusCode::CONFLICT)?;
446        rt.transcript().to_vec()
447    };
448
449    let message_count = transcript_snapshot.len();
450
451    // Build a new session with the copied transcript.
452    let new_id = generate_session_id();
453    let created_at = format_timestamp(SystemTime::now());
454    let system_prompt = state.config.system_prompt.clone();
455
456    let mut runtime = AgentRuntimeBuilder::new()
457        .llm(state.provider.clone())
458        .tools(state.tool_registry.clone())
459        .system_prompt(system_prompt)
460        .max_steps(state.config.max_steps)
461        .build()
462        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
463
464    runtime.set_transcript(transcript_snapshot);
465
466    let plan_approval_gate = runtime.plan_approval_gate();
467    let session = SessionState {
468        id: new_id.clone(),
469        created_at: created_at.clone(),
470        title: None,
471        runtime: Arc::new(tokio::sync::Mutex::new(runtime)),
472        plan_approval_gate,
473        interrupt_token: Arc::new(tokio::sync::Mutex::new(None)),
474    };
475
476    state.sessions.write().await.insert(new_id.clone(), session);
477
478    Ok((
479        StatusCode::CREATED,
480        Json(ForkSessionResponse {
481            id: new_id,
482            created_at,
483            message_count,
484        }),
485    ))
486}
487
488// ── Plan-approval endpoints ───────────────────────────────────────────────
489
490#[derive(serde::Deserialize)]
491pub(super) struct PlanConfirmRequest {
492    /// Optional replacement plan text to use instead of the agent-proposed one.
493    edits: Option<String>,
494}
495
496#[derive(serde::Deserialize)]
497pub(super) struct PlanRejectRequest {
498    /// Reason shown to the agent so it can revise the plan.
499    reason: Option<String>,
500}
501
502/// POST /sessions/:id/plan/confirm — approve the pending plan.
503pub(super) async fn session_plan_confirm(
504    State(state): State<Arc<AppState>>,
505    Path(session_id): Path<String>,
506    Json(body): Json<PlanConfirmRequest>,
507) -> (StatusCode, Json<serde_json::Value>) {
508    let sessions = state.sessions.read().await;
509    let Some(session) = sessions.get(&session_id) else {
510        return (
511            StatusCode::NOT_FOUND,
512            Json(serde_json::json!({"error": "session not found"})),
513        );
514    };
515    let pending = session
516        .plan_approval_gate
517        .pending_plan
518        .read()
519        .ok()
520        .and_then(|g| g.clone());
521    if pending.is_none() {
522        return (
523            StatusCode::CONFLICT,
524            Json(serde_json::json!({"error": "session is not awaiting plan approval"})),
525        );
526    }
527    // Optionally replace the plan text before approving.
528    if let Some(edited) = body.edits {
529        if let Ok(mut w) = session.plan_approval_gate.pending_plan.write() {
530            *w = Some(edited);
531        }
532    }
533    session.plan_approval_gate.approve();
534    (
535        StatusCode::OK,
536        Json(serde_json::json!({"status": "approved", "session_id": session_id})),
537    )
538}
539
540/// POST /sessions/:id/plan/reject — reject the pending plan.
541pub(super) async fn session_plan_reject(
542    State(state): State<Arc<AppState>>,
543    Path(session_id): Path<String>,
544    Json(body): Json<PlanRejectRequest>,
545) -> (StatusCode, Json<serde_json::Value>) {
546    let sessions = state.sessions.read().await;
547    let Some(session) = sessions.get(&session_id) else {
548        return (
549            StatusCode::NOT_FOUND,
550            Json(serde_json::json!({"error": "session not found"})),
551        );
552    };
553    let pending = session
554        .plan_approval_gate
555        .pending_plan
556        .read()
557        .ok()
558        .and_then(|g| g.clone());
559    if pending.is_none() {
560        return (
561            StatusCode::CONFLICT,
562            Json(serde_json::json!({"error": "session is not awaiting plan approval"})),
563        );
564    }
565    let reason = body.reason.unwrap_or_default();
566    session.plan_approval_gate.reject(&reason);
567    (
568        StatusCode::OK,
569        Json(serde_json::json!({"status": "rejected", "session_id": session_id})),
570    )
571}
572
573// ── Goal-168: goal endpoints ──────────────────────────────────────────────
574
575/// POST /sessions/:id/goal — start a condition-based autonomous loop.
576pub(super) async fn session_set_goal(
577    State(state): State<Arc<AppState>>,
578    Path(session_id): Path<String>,
579    Json(body): Json<SetGoalRequest>,
580) -> (StatusCode, Json<serde_json::Value>) {
581    let runtime_arc = {
582        let sessions = state.sessions.read().await;
583        let Some(session) = sessions.get(&session_id) else {
584            return (
585                StatusCode::NOT_FOUND,
586                Json(serde_json::json!({"error": "session not found"})),
587            );
588        };
589        session.runtime.clone()
590    };
591
592    let condition = body.condition.clone();
593    let max_turns = body.max_turns.unwrap_or(20);
594
595    // Lock runtime and set goal state (non-blocking; loop runs in background).
596    match runtime_arc.try_lock() {
597        Ok(runtime) => {
598            runtime.set_goal(condition, max_turns).await;
599        }
600        Err(_) => {
601            return (
602                StatusCode::CONFLICT,
603                Json(serde_json::json!({"error": "session runtime is busy"})),
604            );
605        }
606    }
607
608    (
609        StatusCode::OK,
610        Json(serde_json::json!({"status": "pursuing", "session_id": session_id})),
611    )
612}
613
614/// DELETE /sessions/:id/goal — clear the active goal.
615pub(super) async fn session_clear_goal(
616    State(state): State<Arc<AppState>>,
617    Path(session_id): Path<String>,
618) -> (StatusCode, Json<serde_json::Value>) {
619    let sessions = state.sessions.read().await;
620    let Some(session) = sessions.get(&session_id) else {
621        return (
622            StatusCode::NOT_FOUND,
623            Json(serde_json::json!({"error": "session not found"})),
624        );
625    };
626
627    match session.runtime.try_lock() {
628        Ok(runtime) => {
629            runtime.clear_goal().await;
630        }
631        Err(_) => {
632            // Runtime is busy; force-clear via the shared goal_state.
633            let _ = runtime_goal_state_clear(&session.runtime).await;
634        }
635    }
636
637    (
638        StatusCode::OK,
639        Json(serde_json::json!({"status": "cleared", "session_id": session_id})),
640    )
641}
642
643/// Force-clear goal state when the runtime Mutex is held.
644async fn runtime_goal_state_clear(runtime: &Arc<tokio::sync::Mutex<crate::runtime::AgentRuntime>>) {
645    // Best-effort: try up to 5 times with a small delay.
646    for _ in 0..5u8 {
647        if let Ok(rt) = runtime.try_lock() {
648            rt.clear_goal().await;
649            return;
650        }
651        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
652    }
653}
654
655// ── Goal-170: interrupt endpoint ───────────────────────────────────────────
656
657/// POST /sessions/:id/interrupt — cancel the active agent turn.
658///
659/// Cancels the `CancellationToken` installed at the start of the current
660/// turn. The kernel exits with `FinishReason::Cancelled` at the next step
661/// boundary.  If no turn is in progress the request is still `200 OK`
662/// (idempotent — no harm done).
663pub(super) async fn session_interrupt(
664    State(state): State<Arc<AppState>>,
665    Path(session_id): Path<String>,
666) -> (StatusCode, Json<serde_json::Value>) {
667    let token_arc = {
668        let sessions = state.sessions.read().await;
669        let Some(session) = sessions.get(&session_id) else {
670            return (
671                StatusCode::NOT_FOUND,
672                Json(serde_json::json!({"error": "session not found"})),
673            );
674        };
675        session.interrupt_token.clone()
676    };
677
678    // Cancel the current token if one is installed.
679    let token_opt = token_arc.lock().await.clone();
680    if let Some(token) = token_opt {
681        token.cancel();
682    }
683
684    (
685        StatusCode::OK,
686        Json(serde_json::json!({"status": "interrupted", "session_id": session_id})),
687    )
688}
689
690// ── Goal-169: slash commands endpoint ─────────────────────────────────────
691
692/// GET /slash-commands — list all registered slash commands.
693pub(super) async fn list_slash_commands(
694    State(state): State<Arc<AppState>>,
695) -> Json<Vec<SlashCommandInfo>> {
696    Json((*state.slash_commands).clone())
697}
698
699/// POST /sessions/:id/messages — send a message in a session.
700pub(super) async fn send_session_message(
701    State(state): State<Arc<AppState>>,
702    Path(id): Path<String>,
703    Json(body): Json<SessionMessageRequest>,
704) -> Result<Json<SessionMessageResponse>, (StatusCode, Json<ErrorResponse>)> {
705    // Get the session's runtime and interrupt token (Arc clones are cheap).
706    let (runtime_arc, interrupt_token_arc) = {
707        let sessions = state.sessions.read().await;
708        let session = sessions.get(&id).ok_or((
709            StatusCode::NOT_FOUND,
710            Json(ErrorResponse {
711                status: "error".into(),
712                error: "session not found".into(),
713            }),
714        ))?;
715        (session.runtime.clone(), session.interrupt_token.clone())
716    };
717
718    // Ensure broadcast channel exists for this session before we lock the runtime.
719    let broadcast_tx = {
720        let mut channels = state.event_channels.write().await;
721        let tx = channels.entry(id.clone()).or_insert_with(|| {
722            let (tx, _) = broadcast::channel(64);
723            tx
724        });
725        tx.clone()
726    };
727
728    // Lock the runtime for this turn (serializes concurrent requests per session).
729    let mut runtime = runtime_arc.lock().await;
730
731    // Goal-170: install a fresh cancellation token so `POST .../interrupt`
732    // can cancel this turn without affecting future turns.
733    let interrupt_token = tokio_util::sync::CancellationToken::new();
734    {
735        let mut stored = interrupt_token_arc.lock().await;
736        *stored = Some(interrupt_token.clone());
737    }
738    runtime.set_interrupt_token(interrupt_token);
739
740    // Wire a ChannelSink so events are forwarded to SSE subscribers.
741    let (sink, mut event_rx) = ChannelSink::new();
742    runtime.set_event_sink(Arc::new(sink));
743
744    // Spawn a forwarder: AgentEvent → SseEvent → broadcast channel.
745    // SDK Phase B: track tool call start times so we can emit tool_progress
746    // events with elapsed_ms when each tool finishes.
747    let forward_handle = tokio::spawn(async move {
748        let mut tool_start_times: HashMap<String, std::time::Instant> = HashMap::new();
749        while let Some(ref agent_event) = event_rx.recv().await {
750            // Record start time for each tool call so we can compute elapsed
751            // when the result arrives.
752            if let AgentEvent::ToolCall { id, .. } = agent_event {
753                tool_start_times.insert(id.clone(), std::time::Instant::now());
754            }
755            if let Some(sse_event) = map_agent_event(agent_event) {
756                let _ = broadcast_tx.send(sse_event);
757            }
758            // After forwarding the tool_result, emit tool_progress with timing.
759            if let AgentEvent::ToolResult { id, name, .. } = agent_event {
760                let elapsed_ms = tool_start_times
761                    .remove(id)
762                    .map(|start| start.elapsed().as_millis() as u64)
763                    .unwrap_or(0);
764                let _ = broadcast_tx.send(SseEvent::ToolProgress {
765                    tool_use_id: id.clone(),
766                    tool_name: name.clone(),
767                    elapsed_ms,
768                });
769            }
770        }
771    });
772
773    // Run the agent turn via enqueue so the runtime's FIFO queue is used.
774    let run_result = runtime.enqueue(&body.content).await.map(|opt| {
775        opt.unwrap_or_else(|| crate::runtime::RuntimeOutcome {
776            final_text: None,
777            finish_reason: crate::agent::FinishReason::NoMoreToolCalls,
778            total_usage: crate::TokenUsage::default(),
779            steps: 0,
780            llm_latency_ms: 0,
781            checkpoint_id: None,
782        })
783    });
784
785    // Clear the interrupt token slot — the turn is done.
786    {
787        let mut stored = interrupt_token_arc.lock().await;
788        *stored = None;
789    }
790
791    // Disconnect the sink so the forwarder drains and exits.
792    runtime.set_event_sink(Arc::new(NullSink));
793    let _ = forward_handle.await;
794
795    let _outcome = run_result.map_err(|e| {
796        (
797            StatusCode::INTERNAL_SERVER_ERROR,
798            Json(ErrorResponse {
799                status: "error".into(),
800                error: format!("agent run failed: {e}"),
801            }),
802        )
803    })?;
804
805    // Extract the last assistant message from the runtime's transcript.
806    let last_assistant = runtime
807        .transcript()
808        .iter()
809        .rev()
810        .find(|m| m.role == crate::message::Role::Assistant)
811        .map(|m| m.content.clone())
812        .unwrap_or_default();
813
814    Ok(Json(SessionMessageResponse {
815        role: "assistant".into(),
816        content: last_assistant,
817    }))
818}
819
820// ── SSE endpoint ─────────────────────────────────────────────────────────
821
822/// GET /sessions/:id/events — subscribe to SSE stream of agent events.
823pub(super) async fn session_events(
824    State(state): State<Arc<AppState>>,
825    Path(id): Path<String>,
826) -> Result<Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>>, StatusCode> {
827    // Verify session exists
828    {
829        let sessions = state.sessions.read().await;
830        if !sessions.contains_key(&id) {
831            return Err(StatusCode::NOT_FOUND);
832        }
833    }
834
835    // Get or create broadcast channel for this session
836    let rx = {
837        let mut channels = state.event_channels.write().await;
838        let tx = channels.entry(id.clone()).or_insert_with(|| {
839            let (tx, _) = broadcast::channel(64);
840            tx
841        });
842        tx.subscribe()
843    };
844
845    let stream = BroadcastStream::new(rx).filter_map(|result| match result {
846        Ok(sse_event) => {
847            let event_type = match &sse_event {
848                SseEvent::Message { .. } => "message",
849                SseEvent::PartialMessage { .. } => "partial_message",
850                SseEvent::ToolCall { .. } => "tool_call",
851                SseEvent::ToolResult { .. } => "tool_result",
852                SseEvent::Done { .. } => "done",
853                SseEvent::Error { .. } => "error",
854                SseEvent::PlanProposed { .. } => "plan_proposed",
855                SseEvent::GoalContinuing { .. } => "goal_continuing",
856                SseEvent::GoalAchieved { .. } => "goal_achieved",
857                SseEvent::ToolProgress { .. } => "tool_progress",
858            };
859            let data = serde_json::to_string(&sse_event).unwrap_or_default();
860            Some(Ok(Event::default().event(event_type).data(data)))
861        }
862        Err(_) => None,
863    });
864
865    Ok(Sse::new(stream))
866}
867
868// ── Event mapping ────────────────────────────────────────────────────────
869
870/// Map an [`AgentEvent`] to an [`SseEvent`] for broadcasting to SSE clients.
871///
872/// Returns `None` for events that have no SSE equivalent (latency, tokens, etc.).
873pub fn map_agent_event(event: &AgentEvent) -> Option<SseEvent> {
874    match event {
875        // Streaming token deltas — clients reconstruct the final text by
876        // concatenating deltas keyed on `step`.
877        AgentEvent::PartialToken { text, step } => Some(SseEvent::PartialMessage {
878            text: text.clone(),
879            step: *step,
880        }),
881        // A canonical persisted message — emit it as a typed Message event so
882        // SDK consumers iterating `Run.stream()` get role-tagged content
883        // (assistant text, tool_use blocks). User and tool messages flow
884        // through here too; we only forward roles that are useful to a
885        // streaming consumer.
886        //
887        // We deliberately do NOT also map `AgentEvent::AssistantText` — the
888        // runtime emits both `AssistantText` (per-step) and `MessageAppended`
889        // (once per committed message), so consuming both would produce
890        // duplicate Message events on every assistant turn.
891        AgentEvent::MessageAppended { message, .. }
892        | AgentEvent::MessageAppendedWithAudit { message, .. } => {
893            sse_message_from_canonical(message)
894        }
895        AgentEvent::ToolCall { name, step, .. } => Some(SseEvent::ToolCall {
896            name: name.clone(),
897            step: *step,
898        }),
899        AgentEvent::ToolResult { name, output, .. } => {
900            let success = !output.starts_with("ERROR: ");
901            Some(SseEvent::ToolResult {
902                name: name.clone(),
903                success,
904            })
905        }
906        AgentEvent::TurnFinished { reason, steps } => Some(SseEvent::Done {
907            finish_reason: reason.clone(),
908            total_steps: *steps,
909        }),
910        AgentEvent::PlanProposed { plan_text, .. } => Some(SseEvent::PlanProposed {
911            plan: plan_text.clone(),
912        }),
913        // Goal-168: forward goal-loop progress events.
914        AgentEvent::GoalContinuing { reason, turns } => Some(SseEvent::GoalContinuing {
915            reason: reason.clone(),
916            turns: *turns,
917        }),
918        AgentEvent::GoalAchieved { condition, turns } => Some(SseEvent::GoalAchieved {
919            condition: condition.clone(),
920            turns: *turns,
921        }),
922        // AssistantText, Latency, Usage, Compacted, PlanConfirmed,
923        // PlanRejected don't have SSE equivalents (AssistantText is
924        // intentionally suppressed in favour of MessageAppended above).
925        _ => None,
926    }
927}
928
929/// Convert a canonical [`crate::message::Message`] into an [`SseEvent::Message`].
930///
931/// `system` and `tool` messages are filtered out — system messages carry
932/// internal seeds the SDK consumer never asked for, and tool *result*
933/// messages are already represented by [`SseEvent::ToolResult`].
934fn sse_message_from_canonical(msg: &crate::message::Message) -> Option<SseEvent> {
935    use crate::message::Role;
936    let role = match msg.role {
937        Role::Assistant => "assistant",
938        Role::User => "user",
939        Role::System | Role::Tool => return None,
940    };
941
942    let mut content: Vec<SseContentBlock> = Vec::new();
943    if !msg.content.is_empty() {
944        content.push(SseContentBlock::Text {
945            text: msg.content.clone(),
946        });
947    }
948    for tc in &msg.tool_calls {
949        content.push(SseContentBlock::ToolUse {
950            id: tc.id.clone(),
951            name: tc.name.clone(),
952            input: tc.arguments.clone(),
953        });
954    }
955    if content.is_empty() {
956        return None;
957    }
958    Some(SseEvent::Message {
959        role: role.into(),
960        content,
961    })
962}
963
964// ── AG-UI endpoint ───────────────────────────────────────────────────────
965
966/// State machine that the AG-UI converter uses to coordinate
967/// `TextMessageStart/Content/End` framing across multiple AgentEvents.
968///
969/// We open a TextMessage on the first `AssistantText`/`PartialToken` we see
970/// after every "neutral" point (run start, after `TextMessageEnd`, after
971/// tool-call events) and close it explicitly when we emit a fully-formed
972/// `AssistantText`, when a `ToolCall` arrives, or when the run finishes.
973#[derive(Default)]
974struct AguiConverter {
975    /// `Some(message_id)` when a TextMessageStart has been emitted but no
976    /// TextMessageEnd yet. Used as the `messageId` for streaming
977    /// `PartialToken` deltas and as the `parentMessageId` for tool calls.
978    open_message_id: Option<String>,
979    /// Last fully-emitted (or currently-open) assistant message id. Used as
980    /// the `parent_message_id` on ToolCallStart even after the message has
981    /// been closed, so a client can attribute the tool call back to the
982    /// triggering assistant turn.
983    last_assistant_message_id: Option<String>,
984}
985
986impl AguiConverter {
987    fn new() -> Self {
988        Self::default()
989    }
990
991    /// Translate one [`AgentEvent`] into zero or more AG-UI events,
992    /// updating internal framing state as a side effect.
993    fn convert(&mut self, ev: &AgentEvent) -> Vec<agui_protocol::Event> {
994        use agui_protocol as ag;
995        let mut out = Vec::new();
996        match ev {
997            AgentEvent::AssistantText { text, .. } => {
998                // Close any in-flight streamed message first.
999                if let Some(id) = self.open_message_id.take() {
1000                    out.push(ag::Event::TextMessageEnd(ag::TextMessageEnd {
1001                        message_id: id,
1002                        base: ag::BaseEvent::default(),
1003                    }));
1004                }
1005                let id = uuid::Uuid::new_v4().to_string();
1006                out.push(ag::Event::TextMessageStart(ag::TextMessageStart {
1007                    message_id: id.clone(),
1008                    role: Some("assistant".into()),
1009                    base: ag::BaseEvent::default(),
1010                }));
1011                out.push(ag::Event::TextMessageContent(ag::TextMessageContent {
1012                    message_id: id.clone(),
1013                    delta: text.clone(),
1014                    base: ag::BaseEvent::default(),
1015                }));
1016                out.push(ag::Event::TextMessageEnd(ag::TextMessageEnd {
1017                    message_id: id.clone(),
1018                    base: ag::BaseEvent::default(),
1019                }));
1020                self.last_assistant_message_id = Some(id);
1021                self.open_message_id = None;
1022            }
1023            AgentEvent::PartialToken { text, .. } => {
1024                let id = if let Some(id) = self.open_message_id.clone() {
1025                    id
1026                } else {
1027                    let id = uuid::Uuid::new_v4().to_string();
1028                    out.push(ag::Event::TextMessageStart(ag::TextMessageStart {
1029                        message_id: id.clone(),
1030                        role: Some("assistant".into()),
1031                        base: ag::BaseEvent::default(),
1032                    }));
1033                    self.open_message_id = Some(id.clone());
1034                    self.last_assistant_message_id = Some(id.clone());
1035                    id
1036                };
1037                out.push(ag::Event::TextMessageContent(ag::TextMessageContent {
1038                    message_id: id,
1039                    delta: text.clone(),
1040                    base: ag::BaseEvent::default(),
1041                }));
1042            }
1043            AgentEvent::ToolCall {
1044                id,
1045                name,
1046                arguments,
1047                ..
1048            } => {
1049                // Close any in-flight streamed assistant message first; the
1050                // assistant turn is "done" the moment a tool call lands.
1051                if let Some(open) = self.open_message_id.take() {
1052                    out.push(ag::Event::TextMessageEnd(ag::TextMessageEnd {
1053                        message_id: open,
1054                        base: ag::BaseEvent::default(),
1055                    }));
1056                }
1057                out.push(ag::Event::ToolCallStart(ag::ToolCallStart {
1058                    tool_call_id: id.clone(),
1059                    tool_call_name: name.clone(),
1060                    parent_message_id: self.last_assistant_message_id.clone(),
1061                    base: ag::BaseEvent::default(),
1062                }));
1063                out.push(ag::Event::ToolCallArgs(ag::ToolCallArgs {
1064                    tool_call_id: id.clone(),
1065                    delta: arguments.clone(),
1066                    base: ag::BaseEvent::default(),
1067                }));
1068                out.push(ag::Event::ToolCallEnd(ag::ToolCallEnd {
1069                    tool_call_id: id.clone(),
1070                    base: ag::BaseEvent::default(),
1071                }));
1072            }
1073            AgentEvent::ToolResult { id, output, .. } => {
1074                // AG-UI requires a `messageId` on ToolCallResult; reuse the
1075                // most recent assistant message id as the conversational
1076                // anchor (mirrors what OpenAI's tool message shape does).
1077                let message_id = self
1078                    .last_assistant_message_id
1079                    .clone()
1080                    .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1081                out.push(ag::Event::ToolCallResult(ag::ToolCallResult {
1082                    tool_call_id: id.clone(),
1083                    message_id,
1084                    content: output.clone(),
1085                    role: Some("tool".into()),
1086                    base: ag::BaseEvent::default(),
1087                }));
1088            }
1089            AgentEvent::TurnFinished { .. } => {
1090                // Close any in-flight streamed message before signalling
1091                // run completion to the client.
1092                if let Some(open) = self.open_message_id.take() {
1093                    out.push(ag::Event::TextMessageEnd(ag::TextMessageEnd {
1094                        message_id: open,
1095                        base: ag::BaseEvent::default(),
1096                    }));
1097                }
1098                // Actual RunFinished is emitted by the caller (it knows
1099                // the thread/run ids); we just flush state here.
1100            }
1101            // TODO(g141, g140): map permission_request / checkpoint_post /
1102            // heartbeat / file_artifact onto Custom events here.
1103            // Other variants (Latency, Usage, Compacted, PlanProposed,
1104            // PlanConfirmed, PlanRejected) have no AG-UI standard
1105            // equivalent and are intentionally dropped.
1106            _ => {}
1107        }
1108        out
1109    }
1110}
1111
1112/// Stateless wrapper: maps a single [`AgentEvent`] to AG-UI events
1113/// using a fresh converter. Useful in tests; production code uses
1114/// [`AguiConverter::convert`] directly so framing state survives
1115/// across the whole run.
1116#[cfg(test)]
1117fn agui_events_for(ev: &AgentEvent) -> Vec<agui_protocol::Event> {
1118    AguiConverter::new().convert(ev)
1119}
1120
1121/// POST /agui — drive an agent run via the AG-UI protocol and stream
1122/// AG-UI events back as SSE.
1123pub(super) async fn agui_run(
1124    State(state): State<Arc<AppState>>,
1125    Json(body): Json<serde_json::Value>,
1126) -> Result<
1127    Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>>,
1128    (StatusCode, Json<ErrorResponse>),
1129> {
1130    use agui_protocol as ag;
1131
1132    // Parse the body into a typed RunAgentInput. We accept Json<Value>
1133    // up top so we can return a clean 400 with a helpful message
1134    // instead of axum's default 422 on shape errors.
1135    let input: ag::RunAgentInput = serde_json::from_value(body).map_err(|e| {
1136        (
1137            StatusCode::BAD_REQUEST,
1138            Json(ErrorResponse {
1139                status: "error".into(),
1140                error: format!("invalid AG-UI RunAgentInput: {e}"),
1141            }),
1142        )
1143    })?;
1144
1145    // Derive the user goal: prefer the last user message, else fall back
1146    // to the first context item value.
1147    let goal = input
1148        .messages
1149        .iter()
1150        .rev()
1151        .find(|m| m.role == "user")
1152        .and_then(|m| m.content.clone())
1153        .or_else(|| input.context.first().map(|c| c.value.clone()))
1154        .filter(|s| !s.trim().is_empty())
1155        .ok_or_else(|| {
1156            (
1157                StatusCode::BAD_REQUEST,
1158                Json(ErrorResponse {
1159                    status: "error".into(),
1160                    error: "RunAgentInput must contain at least one user \
1161                            message or a non-empty context item"
1162                        .into(),
1163                }),
1164            )
1165        })?;
1166
1167    let mut runtime = AgentRuntimeBuilder::new()
1168        .llm(state.provider.clone())
1169        .tools(state.tool_registry.clone())
1170        .system_prompt(state.config.system_prompt.clone())
1171        .max_steps(state.config.max_steps)
1172        .build()
1173        .map_err(|e| {
1174            (
1175                StatusCode::INTERNAL_SERVER_ERROR,
1176                Json(ErrorResponse {
1177                    status: "error".into(),
1178                    error: format!("failed to build runtime: {e}"),
1179                }),
1180            )
1181        })?;
1182
1183    // Wire per-turn workspace checkpoints. The AG-UI thread is the
1184    // natural session boundary, so we use a sanitised version of the
1185    // thread_id as the checkpoint chain id. Failures (no git on PATH,
1186    // bad workspace path, etc.) only log a warning — the run still
1187    // proceeds without checkpoints.
1188    if let Ok(repo) = crate::ShadowRepo::open(&state.config.workspace) {
1189        let session_id = sanitize_thread_id_for_session(&input.thread_id);
1190        if let Ok(session_dir) = crate::user_sessions_dir(&state.config.workspace) {
1191            let log_dir = session_dir.join(format!("agui-{session_id}"));
1192            let _ = std::fs::create_dir_all(&log_dir);
1193            let log_path = log_dir.join("checkpoints.jsonl");
1194            let touched = runtime.kernel().tools().touched_files();
1195            if let Err(e) =
1196                runtime.enable_checkpoints(Arc::new(repo), session_id, log_path, touched)
1197            {
1198                tracing::warn!("agui: enable_checkpoints failed, continuing without: {e}");
1199            }
1200        }
1201    } else {
1202        tracing::debug!("agui: shadow git unavailable, no per-turn checkpoints");
1203    }
1204
1205    let (sink, mut event_rx) = ChannelSink::new();
1206    runtime.set_event_sink(Arc::new(sink));
1207
1208    // Channel that carries fully-converted AG-UI Events to the SSE stream.
1209    let (sse_tx, sse_rx) = tokio::sync::mpsc::unbounded_channel::<ag::Event>();
1210    let thread_id = input.thread_id.clone();
1211    let run_id = input.run_id.clone();
1212
1213    // Emit RunStarted up front so clients can render the run shell
1214    // before the first model token arrives.
1215    let _ = sse_tx.send(ag::Event::RunStarted(ag::RunStarted {
1216        thread_id: thread_id.clone(),
1217        run_id: run_id.clone(),
1218        base: ag::BaseEvent::default(),
1219    }));
1220
1221    // Converter task: forward AgentEvents → AG-UI Events. Owns the
1222    // AguiConverter so framing state survives across the whole run.
1223    // It does NOT emit RunFinished — the driver task does that after
1224    // it can also surface the optional checkpoint_post Custom event.
1225    let conv_tx = sse_tx.clone();
1226    let converter_handle = tokio::spawn(async move {
1227        let mut conv = AguiConverter::new();
1228        while let Some(agent_event) = event_rx.recv().await {
1229            for ev in conv.convert(&agent_event) {
1230                if conv_tx.send(ev).is_err() {
1231                    return;
1232                }
1233            }
1234        }
1235    });
1236
1237    // Drive the agent on a background task so the response stream can
1238    // flush bytes to the client incrementally. Order of events emitted
1239    // by the driver after run() returns:
1240    //   1. Wait for the converter to drain all AgentEvents.
1241    //   2. If a checkpoint id was produced, emit
1242    //      Custom("agui-tui/checkpoint_post").
1243    //   3. Emit RunFinished — always last.
1244    let metrics = state.metrics.clone();
1245    let drv_thread = thread_id.clone();
1246    let drv_run = run_id.clone();
1247    tokio::spawn(async move {
1248        let outcome = runtime.run(&goal).await;
1249        // Replace the sink so the converter task's recv() sees a closed
1250        // channel and exits cleanly.
1251        runtime.set_event_sink(Arc::new(NullSink));
1252
1253        // Snapshot what we need from the outcome before metrics consume it.
1254        let (checkpoint_id, finished_turn): (Option<String>, Option<usize>) = match &outcome {
1255            Ok(o) => (
1256                o.checkpoint_id.as_ref().map(|c| c.0.clone()),
1257                runtime.turn_index().checked_sub(1),
1258            ),
1259            Err(_) => (None, None),
1260        };
1261
1262        match outcome {
1263            Ok(o) => {
1264                metrics.agent_runs_total.fetch_add(1, Ordering::Relaxed);
1265                metrics.agent_runs_success.fetch_add(1, Ordering::Relaxed);
1266                metrics
1267                    .agent_steps_total
1268                    .fetch_add(o.steps as u64, Ordering::Relaxed);
1269                metrics
1270                    .tokens_prompt_total
1271                    .fetch_add(o.total_usage.prompt_tokens as u64, Ordering::Relaxed);
1272                metrics
1273                    .tokens_completion_total
1274                    .fetch_add(o.total_usage.completion_tokens as u64, Ordering::Relaxed);
1275            }
1276            Err(_) => {
1277                metrics.agent_runs_total.fetch_add(1, Ordering::Relaxed);
1278                metrics.agent_runs_failed.fetch_add(1, Ordering::Relaxed);
1279            }
1280        }
1281
1282        // Wait for the converter task to translate the last AgentEvent
1283        // before we emit anything else, so checkpoint_post and
1284        // RunFinished are guaranteed to arrive last.
1285        let _ = converter_handle.await;
1286
1287        if let (Some(cp), Some(turn)) = (checkpoint_id, finished_turn) {
1288            let _ = sse_tx.send(ag::Event::Custom(ag::Custom {
1289                name: "agui-tui/checkpoint_post".into(),
1290                value: serde_json::json!({
1291                    "turn": turn,
1292                    "postId": cp,
1293                }),
1294                base: ag::BaseEvent::default(),
1295            }));
1296        }
1297
1298        let _ = sse_tx.send(ag::Event::RunFinished(ag::RunFinished {
1299            thread_id: drv_thread,
1300            run_id: drv_run,
1301            result: None,
1302            base: ag::BaseEvent::default(),
1303        }));
1304    });
1305
1306    let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(sse_rx).map(|ev| {
1307        let data = serde_json::to_string(&ev).unwrap_or_else(|_| "{}".into());
1308        Ok::<_, Infallible>(Event::default().data(data))
1309    });
1310
1311    Ok(Sse::new(stream))
1312}
1313
1314/// Map an arbitrary AG-UI thread id onto a checkpoint session id that
1315/// satisfies `validate_session_id` in the checkpoint module
1316/// (alphanumerics + `-` `_` `.`, no leading dot, no `..`, no path
1317/// separators). Disallowed chars become `-`.
1318fn sanitize_thread_id_for_session(thread: &str) -> String {
1319    let mut out: String = thread
1320        .chars()
1321        .map(|c| {
1322            if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
1323                c
1324            } else {
1325                '-'
1326            }
1327        })
1328        .collect();
1329    // Drop a leading dot so we don't produce a hidden dir.
1330    while out.starts_with('.') {
1331        out.replace_range(..1, "-");
1332    }
1333    // Collapse `..` so we don't produce ref-traversal sequences.
1334    while out.contains("..") {
1335        out = out.replace("..", "-.");
1336    }
1337    if out.is_empty() {
1338        out.push_str("default");
1339    }
1340    out
1341}
1342
1343/// GET /metrics — Prometheus exposition format.
1344pub(super) async fn metrics_handler(State(state): State<Arc<AppState>>) -> String {
1345    let metrics = &state.metrics;
1346    let requests_total = metrics.requests_total.load(Ordering::Relaxed);
1347    let requests_active = metrics.requests_active.load(Ordering::Relaxed);
1348    let agent_runs_total = metrics.agent_runs_total.load(Ordering::Relaxed);
1349    let agent_runs_success = metrics.agent_runs_success.load(Ordering::Relaxed);
1350    let agent_runs_failed = metrics.agent_runs_failed.load(Ordering::Relaxed);
1351    let tokens_prompt_total = metrics.tokens_prompt_total.load(Ordering::Relaxed);
1352    let tokens_completion_total = metrics.tokens_completion_total.load(Ordering::Relaxed);
1353    let agent_steps_total = metrics.agent_steps_total.load(Ordering::Relaxed);
1354
1355    format!(
1356        "# HELP recursive_requests_total Total HTTP requests\n\
1357         # TYPE recursive_requests_total counter\n\
1358         recursive_requests_total {requests_total}\n\
1359         # HELP recursive_requests_active Currently active HTTP requests\n\
1360         # TYPE recursive_requests_active gauge\n\
1361         recursive_requests_active {requests_active}\n\
1362         # HELP recursive_agent_runs_total Total agent runs\n\
1363         # TYPE recursive_agent_runs_total counter\n\
1364         recursive_agent_runs_total {agent_runs_total}\n\
1365         # HELP recursive_agent_runs_success Successful agent runs\n\
1366         # TYPE recursive_agent_runs_success counter\n\
1367         recursive_agent_runs_success {agent_runs_success}\n\
1368         # HELP recursive_agent_runs_failed Failed agent runs\n\
1369         # TYPE recursive_agent_runs_failed counter\n\
1370         recursive_agent_runs_failed {agent_runs_failed}\n\
1371         # HELP recursive_tokens_prompt_total Total prompt tokens consumed\n\
1372         # TYPE recursive_tokens_prompt_total counter\n\
1373         recursive_tokens_prompt_total {tokens_prompt_total}\n\
1374         # HELP recursive_tokens_completion_total Total completion tokens generated\n\
1375         # TYPE recursive_tokens_completion_total counter\n\
1376         recursive_tokens_completion_total {tokens_completion_total}\n\
1377         # HELP recursive_agent_steps_total Total agent steps executed\n\
1378         # TYPE recursive_agent_steps_total counter\n\
1379         recursive_agent_steps_total {agent_steps_total}\n"
1380    )
1381}
1382
1383#[cfg(test)]
1384mod tests {
1385    use super::*;
1386    use crate::event::AgentEvent;
1387    use crate::http::SseEvent;
1388
1389    #[test]
1390    fn agui_events_for_assistant_text_emits_start_content_end() {
1391        use agui_protocol as ag;
1392        let ev = AgentEvent::AssistantText {
1393            text: "hi".into(),
1394            step: 0,
1395        };
1396        let out = agui_events_for(&ev);
1397        assert_eq!(out.len(), 3, "got {out:?}");
1398        assert!(matches!(out[0], ag::Event::TextMessageStart(_)));
1399        assert!(matches!(out[1], ag::Event::TextMessageContent(_)));
1400        assert!(matches!(out[2], ag::Event::TextMessageEnd(_)));
1401    }
1402
1403    // ── SDK Phase B: tool_progress forwarder ─────────────────────────────
1404
1405    /// Verify that the stateful forwarder logic correctly emits ToolProgress
1406    /// after ToolResult with the right tool_name.  We simulate the forwarder's
1407    /// HashMap bookkeeping without spinning up a full Tokio task.
1408    #[test]
1409    fn tool_progress_emitted_after_tool_result() {
1410        use std::collections::HashMap;
1411        use std::time::Instant;
1412
1413        let mut tool_start_times: HashMap<String, Instant> = HashMap::new();
1414        let mut emitted: Vec<SseEvent> = Vec::new();
1415
1416        // Simulate ToolCall arrival
1417        let call_event = AgentEvent::ToolCall {
1418            name: "run_shell".to_string(),
1419            id: "tc-1".to_string(),
1420            arguments: "{}".to_string(),
1421            step: 0,
1422        };
1423        if let AgentEvent::ToolCall { id, .. } = &call_event {
1424            tool_start_times.insert(id.clone(), Instant::now());
1425        }
1426        if let Some(ev) = map_agent_event(&call_event) {
1427            emitted.push(ev);
1428        }
1429
1430        // Simulate ToolResult arrival (no sleep needed — elapsed_ms ≥ 0)
1431        let result_event = AgentEvent::ToolResult {
1432            id: "tc-1".to_string(),
1433            name: "run_shell".to_string(),
1434            output: "ok".to_string(),
1435            step: 0,
1436        };
1437        if let Some(ev) = map_agent_event(&result_event) {
1438            emitted.push(ev);
1439        }
1440        if let AgentEvent::ToolResult { id, name, .. } = &result_event {
1441            let elapsed_ms = tool_start_times
1442                .remove(id)
1443                .map(|start| start.elapsed().as_millis() as u64)
1444                .unwrap_or(0);
1445            emitted.push(SseEvent::ToolProgress {
1446                tool_use_id: id.clone(),
1447                tool_name: name.clone(),
1448                elapsed_ms,
1449            });
1450        }
1451
1452        // Expect: ToolCall, ToolResult, ToolProgress
1453        assert_eq!(emitted.len(), 3, "expected 3 events");
1454        assert!(matches!(emitted[0], SseEvent::ToolCall { .. }));
1455        assert!(matches!(emitted[1], SseEvent::ToolResult { .. }));
1456        let SseEvent::ToolProgress {
1457            tool_use_id,
1458            tool_name,
1459            elapsed_ms,
1460        } = &emitted[2]
1461        else {
1462            panic!("third event should be ToolProgress");
1463        };
1464        assert_eq!(tool_use_id, "tc-1");
1465        assert_eq!(tool_name, "run_shell");
1466        let _ = elapsed_ms; // ≥ 0 is trivially true for u64
1467    }
1468
1469    /// Verify that tool_start_times does NOT grow if a ToolResult arrives
1470    /// without a matching ToolCall (e.g. replayed events).
1471    #[test]
1472    fn tool_progress_elapsed_is_zero_for_unmatched_result() {
1473        use std::collections::HashMap;
1474        use std::time::Instant;
1475
1476        let mut tool_start_times: HashMap<String, Instant> = HashMap::new();
1477
1478        let result_event = AgentEvent::ToolResult {
1479            id: "tc-orphan".to_string(),
1480            name: "read_file".to_string(),
1481            output: "data".to_string(),
1482            step: 0,
1483        };
1484        let elapsed_ms = if let AgentEvent::ToolResult { id, .. } = &result_event {
1485            tool_start_times
1486                .remove(id)
1487                .map(|start| start.elapsed().as_millis() as u64)
1488                .unwrap_or(0)
1489        } else {
1490            unreachable!()
1491        };
1492        // No panic; elapsed defaults to 0 when no matching ToolCall.
1493        assert_eq!(elapsed_ms, 0);
1494    }
1495}