1use 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#[cfg_attr(test, mutants::skip)]
32pub(super) async fn health() -> &'static str {
33 "ok"
34}
35
36fn 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
51fn 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
57fn 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 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 _ => ApiError::internal(e.to_string()),
93 }
94}
95
96#[cfg_attr(test, mutants::skip)]
98pub(super) async fn openapi_spec() -> Json<serde_json::Value> {
99 Json(build_openapi_spec())
100}
101
102#[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 if body.goal.trim().is_empty() {
114 return Err(ApiError::bad_request("missing or empty 'goal' field"));
115 }
116
117 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 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 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
196fn 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#[cfg_attr(test, mutants::skip)]
216fn generate_session_id() -> String {
217 uuid::Uuid::now_v7().to_string()
218}
219
220fn format_timestamp(t: SystemTime) -> String {
222 let dur = t.duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default();
223 let secs = dur.as_secs();
224 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 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
240pub(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 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 runtime.set_session_id(&id);
293
294 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#[derive(serde::Serialize)]
330pub(super) struct SessionList {
331 pub total: usize,
332 pub sessions: Vec<SessionInfo>,
333}
334
335pub(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 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 infos.sort_by(|a, b| a.created_at.cmp(&b.created_at).then(a.id.cmp(&b.id)));
365 let total = infos.len();
367 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
380pub(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 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 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 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 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
461pub(super) async fn delete_session(
463 State(state): State<Arc<AppState>>,
464 Path(id): Path<String>,
465) -> Result<StatusCode, ApiError> {
466 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 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 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#[derive(serde::Deserialize, Debug)]
498pub(super) struct PatchSessionRequest {
499 title: Option<String>,
501}
502
503pub(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 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#[derive(serde::Serialize)]
541pub(super) struct ForkSessionResponse {
542 id: String,
544 created_at: String,
546 message_count: usize,
550}
551
552pub(super) async fn fork_session(
560 State(state): State<Arc<AppState>>,
561 Path(id): Path<String>,
562) -> Result<(StatusCode, Json<ForkSessionResponse>), ApiError> {
563 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 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 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 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#[derive(serde::Deserialize)]
643pub(super) struct PlanConfirmRequest {
644 edits: Option<String>,
646}
647
648#[derive(serde::Deserialize)]
649pub(super) struct PlanRejectRequest {
650 reason: Option<String>,
652}
653
654pub(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 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
686pub(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
713pub(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 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
748pub(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 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 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
792async 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
809pub(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 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#[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
852pub(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 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 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 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 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 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 let (sink, mut event_rx) = ChannelSink::new();
919 runtime.set_event_sink(Arc::new(sink));
920
921 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 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 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 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 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 {
980 let mut stored = interrupt_token_arc.lock().await;
981 *stored = None;
982 }
983
984 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 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 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
1016pub(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 {
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 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 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 let heartbeat_stream = IntervalStream::new(tokio::time::interval(Duration::from_secs(30)))
1067 .map(|_| Ok::<Event, Infallible>(Event::default().comment("heartbeat")));
1068
1069 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
1078pub fn map_agent_event(event: &AgentEvent) -> Option<SseEvent> {
1084 match event {
1085 AgentEvent::PartialToken { text, step } => Some(SseEvent::PartialMessage {
1088 text: text.clone(),
1089 step: *step,
1090 }),
1091 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 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 _ => None,
1136 }
1137}
1138
1139fn 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#[derive(Default)]
1184struct AguiConverter {
1185 open_message_id: Option<String>,
1189 last_assistant_message_id: Option<String>,
1194}
1195
1196impl AguiConverter {
1197 fn new() -> Self {
1198 Self::default()
1199 }
1200
1201 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 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 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 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 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 }
1311 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 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 _ => {}
1387 }
1388 out
1389 }
1390}
1391
1392#[cfg(test)]
1397fn agui_events_for(ev: &AgentEvent) -> Vec<agui_protocol::Event> {
1398 AguiConverter::new().convert(ev)
1399}
1400
1401pub(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 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 let resume_items: Vec<ag::Resume> = input.resume.unwrap_or_default();
1429
1430 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 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 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 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 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 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 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 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 modified.push(crate::message::Message::tool_result(
1578 tool_call_id,
1579 &payload_str,
1580 ));
1581 }
1582 }
1583 }
1584
1585 clear_open_interrupts(&session_dir);
1587
1588 seed_transcript = Some(modified);
1589 }
1590
1591 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 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 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 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 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 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 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 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 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 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 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 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 let metrics = state.metrics.clone();
1785 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 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 runtime.set_event_sink(Arc::new(NullSink));
1825
1826 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 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 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 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 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 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 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 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 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 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
2043fn 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 while out.starts_with('.') {
2060 out.replace_range(..1, "-");
2061 }
2062 while out.contains("..") {
2064 out = out.replace("..", "-.");
2065 }
2066 if out.is_empty() {
2067 out.push_str("default");
2068 }
2069 out
2070}
2071
2072fn 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#[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#[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
2107fn 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
2116fn 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
2126fn 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
2133struct TestInterruptHook {
2138 interrupt_before: Vec<String>,
2139 interrupted_tool_call_id: std::sync::Mutex<Option<String>>,
2141 interrupted_tool_name: std::sync::Mutex<Option<String>>,
2143 interrupted_arguments: std::sync::Mutex<Option<serde_json::Value>>,
2145}
2146
2147pub(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 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 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 return crate::agent::PermissionDecision::Allow;
2228 }
2229 if self.interrupt_before.iter().any(|n| n == name) {
2230 *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
2249pub(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 #[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 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 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 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; }
2383
2384 #[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 assert_eq!(elapsed_ms, 0);
2410 }
2411
2412 #[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 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 #[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 let guard = runtime_arc.lock().await;
2522
2523 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(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 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 {
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 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 #[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 #[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 assert_eq!(
2760 metrics.sessions_active.load(Ordering::Relaxed),
2761 0,
2762 "sessions_active should start at 0"
2763 );
2764
2765 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 assert_eq!(
2790 metrics.sessions_active.load(Ordering::Relaxed),
2791 1,
2792 "sessions_active should increment to 1 after create"
2793 );
2794
2795 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 assert_eq!(
2815 metrics.sessions_active.load(Ordering::Relaxed),
2816 0,
2817 "sessions_active should decrement to 0 after delete"
2818 );
2819 }
2820
2821 #[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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 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 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 #[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 assert_eq!(sp, base_prompt);
3025 }
3026
3027 #[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 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 #[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 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 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 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 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 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 #[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 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 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 #[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}