xcodeai/http/routes.rs
1//! Route handlers for the xcodeai HTTP API.
2//!
3//! Endpoints implemented here:
4//! - `POST /sessions` — create a new session
5//! - `GET /sessions` — list recent sessions (default: latest 50)
6//! - `GET /sessions/:id` — get one session + its messages
7//! - `DELETE /sessions/:id` — delete a session and its messages
8//!
9//! Agent execution (`POST /sessions/:id/messages`) is implemented in Task 34.
10//!
11//! ## Locking convention
12//!
13//! All handlers acquire `state.store.lock().await` before any DB call,
14//! because rusqlite's Connection is `!Sync` (uses RefCell internally).
15use crate::http::AppState;
16use axum::{
17 extract::{Path, Query, State},
18 http::StatusCode,
19 response::{
20 sse::{Event, KeepAlive, Sse},
21 IntoResponse,
22 },
23 routing::{delete, get, post},
24 Json, Router,
25};
26use futures_util::stream;
27use serde::{Deserialize, Serialize};
28use std::convert::Infallible;
29use std::sync::Arc;
30
31// ─── Request / Response types ─────────────────────────────────────────────────
32
33/// Body for `POST /sessions`.
34///
35/// All fields are optional: a bare `{}` creates an untitled session.
36#[derive(Debug, Deserialize)]
37pub struct CreateSessionRequest {
38 /// Optional human-readable title (e.g. "Refactor auth module").
39 pub title: Option<String>,
40}
41
42/// Query-string parameters for `GET /sessions`.
43#[derive(Debug, Deserialize)]
44pub struct ListSessionsQuery {
45 /// How many sessions to return (default: 50, max enforced by caller).
46 #[serde(default = "default_limit")]
47 pub limit: u32,
48}
49
50fn default_limit() -> u32 {
51 50
52}
53
54/// A single session entry returned in list / get responses.
55#[derive(Debug, Serialize)]
56pub struct SessionResponse {
57 pub id: String,
58 pub title: Option<String>,
59 pub created_at: String,
60 pub updated_at: String,
61}
62
63/// Full session with messages, returned by `GET /sessions/:id`.
64#[derive(Debug, Serialize)]
65pub struct SessionDetailResponse {
66 pub id: String,
67 pub title: Option<String>,
68 pub created_at: String,
69 pub updated_at: String,
70 /// All messages in chronological order.
71 pub messages: Vec<MessageResponse>,
72}
73
74/// A single stored message.
75#[derive(Debug, Serialize)]
76pub struct MessageResponse {
77 pub id: String,
78 pub role: String,
79 pub content: Option<String>,
80 pub created_at: String,
81}
82
83/// Generic JSON error body.
84#[derive(Debug, Serialize)]
85struct ErrorResponse {
86 error: String,
87}
88
89// ─── Router factory ───────────────────────────────────────────────────────────
90
91/// Build the session sub-router.
92pub fn session_router() -> Router<Arc<AppState>> {
93 Router::new()
94 .route("/sessions", post(create_session))
95 .route("/sessions", get(list_sessions))
96 .route("/sessions/:id", get(get_session))
97 .route("/sessions/:id", delete(delete_session))
98 .route("/sessions/:id/messages", post(post_message))
99}
100
101// ─── Handlers ─────────────────────────────────────────────────────────────────
102
103/// `POST /sessions` — create a new session.
104///
105/// Returns 201 Created with `{"session_id": "<uuid>"}` on success.
106pub async fn create_session(
107 State(state): State<Arc<AppState>>,
108 body: Option<Json<CreateSessionRequest>>,
109) -> impl IntoResponse {
110 let title = body.and_then(|b| b.title.clone());
111 let store = state.store.lock().await;
112 match store.create_session(title.as_deref()) {
113 Ok(session) => (
114 StatusCode::CREATED,
115 Json(serde_json::json!({ "session_id": session.id })),
116 )
117 .into_response(),
118 Err(e) => (
119 StatusCode::INTERNAL_SERVER_ERROR,
120 Json(ErrorResponse {
121 error: e.to_string(),
122 }),
123 )
124 .into_response(),
125 }
126}
127
128/// `GET /sessions?limit=N` — list recent sessions.
129///
130/// Returns 200 OK with a JSON array of session objects, ordered newest first.
131pub async fn list_sessions(
132 State(state): State<Arc<AppState>>,
133 Query(params): Query<ListSessionsQuery>,
134) -> impl IntoResponse {
135 let limit = params.limit.min(200);
136 let store = state.store.lock().await;
137 match store.list_sessions(limit) {
138 Ok(sessions) => {
139 let resp: Vec<SessionResponse> = sessions
140 .into_iter()
141 .map(|s| SessionResponse {
142 id: s.id,
143 title: s.title,
144 created_at: s.created_at.to_rfc3339(),
145 updated_at: s.updated_at.to_rfc3339(),
146 })
147 .collect();
148 (StatusCode::OK, Json(resp)).into_response()
149 }
150 Err(e) => (
151 StatusCode::INTERNAL_SERVER_ERROR,
152 Json(ErrorResponse {
153 error: e.to_string(),
154 }),
155 )
156 .into_response(),
157 }
158}
159
160/// `GET /sessions/:id` — get a single session with all messages.
161///
162/// Returns 200 OK with a `SessionDetailResponse`, or 404 if not found.
163pub async fn get_session(
164 State(state): State<Arc<AppState>>,
165 Path(id): Path<String>,
166) -> impl IntoResponse {
167 let store = state.store.lock().await;
168
169 let session = match store.get_session(&id) {
170 Ok(Some(s)) => s,
171 Ok(None) => {
172 return (
173 StatusCode::NOT_FOUND,
174 Json(ErrorResponse {
175 error: format!("Session '{id}' not found"),
176 }),
177 )
178 .into_response()
179 }
180 Err(e) => {
181 return (
182 StatusCode::INTERNAL_SERVER_ERROR,
183 Json(ErrorResponse {
184 error: e.to_string(),
185 }),
186 )
187 .into_response()
188 }
189 };
190
191 let messages = match store.get_messages(&id) {
192 Ok(msgs) => msgs,
193 Err(e) => {
194 return (
195 StatusCode::INTERNAL_SERVER_ERROR,
196 Json(ErrorResponse {
197 error: e.to_string(),
198 }),
199 )
200 .into_response()
201 }
202 };
203
204 let detail = SessionDetailResponse {
205 id: session.id,
206 title: session.title,
207 created_at: session.created_at.to_rfc3339(),
208 updated_at: session.updated_at.to_rfc3339(),
209 messages: messages
210 .into_iter()
211 .map(|m| MessageResponse {
212 id: m.id,
213 role: m.role,
214 content: m.content,
215 created_at: m.created_at.to_rfc3339(),
216 })
217 .collect(),
218 };
219
220 (StatusCode::OK, Json(detail)).into_response()
221}
222
223/// `DELETE /sessions/:id` — delete a session and all its messages.
224///
225/// Returns 204 No Content on success, 404 if not found.
226pub async fn delete_session(
227 State(state): State<Arc<AppState>>,
228 Path(id): Path<String>,
229) -> impl IntoResponse {
230 let store = state.store.lock().await;
231
232 // Verify the session exists first so we can return a proper 404.
233 match store.get_session(&id) {
234 Ok(None) => {
235 return (
236 StatusCode::NOT_FOUND,
237 Json(ErrorResponse {
238 error: format!("Session '{id}' not found"),
239 }),
240 )
241 .into_response()
242 }
243 Err(e) => {
244 return (
245 StatusCode::INTERNAL_SERVER_ERROR,
246 Json(ErrorResponse {
247 error: e.to_string(),
248 }),
249 )
250 .into_response()
251 }
252 Ok(Some(_)) => {} // proceed
253 }
254
255 match store.delete_session(&id) {
256 Ok(()) => StatusCode::NO_CONTENT.into_response(),
257 Err(e) => (
258 StatusCode::INTERNAL_SERVER_ERROR,
259 Json(ErrorResponse {
260 error: e.to_string(),
261 }),
262 )
263 .into_response(),
264 }
265}
266
267// ─── POST /sessions/:id/messages ────────────────────────────────────────────
268
269/// Request body for `POST /sessions/:id/messages`.
270///
271/// Sends a user message to an existing session and runs the agent loop,
272/// streaming the output as Server-Sent Events.
273#[derive(Debug, Deserialize)]
274pub struct PostMessageRequest {
275 /// The task/message text for the agent.
276 pub content: String,
277 /// Optional list of image file paths to attach as multimodal content.
278 /// Each entry is a filesystem path; the handler reads and base64-encodes it.
279 #[serde(default)]
280 #[allow(dead_code)]
281 pub images: Vec<String>,
282}
283
284/// `POST /sessions/:id/messages`
285///
286/// Accepts a JSON body `{ "content": "...", "images": [...] }`, persists the
287/// user message in the session, then runs the CoderAgent loop and streams
288/// all agent output as a Server-Sent Events response.
289///
290/// ## SSE event types
291///
292/// | event | data fields |
293/// |-------------|----------------------------------------|
294/// | `status` | `{"msg": "..."}` — progress update |
295/// | `tool_call` | `{"name": "...", "args": "..."}` — tool invocation |
296/// | `tool_result` | `{"preview": "...", "is_error": bool}` — tool result |
297/// | `error` | `{"msg": "..."}` — agent-level error |
298/// | `complete` | `{}` — agent finished, stream ends |
299///
300/// ## Error responses (non-SSE)
301///
302/// - `404 Not Found` — session ID does not exist
303/// - `409 Conflict` — session already has an active agent execution
304/// - `500 Internal Server Error` — failed to start agent
305pub async fn post_message(
306 State(state): State<Arc<AppState>>,
307 Path(session_id): Path<String>,
308 Json(body): Json<PostMessageRequest>,
309) -> impl IntoResponse {
310 use crate::agent::director::Director;
311 use crate::agent::Agent;
312 use crate::context::AgentContext;
313 use crate::io::http::HttpIO;
314 use crate::llm;
315
316 // ── 1. Verify the session exists ──────────────────────────────────────
317 {
318 let store = state.store.lock().await;
319 match store.get_session(&session_id) {
320 Ok(Some(_)) => {} // exists, proceed
321 Ok(None) => {
322 return (
323 StatusCode::NOT_FOUND,
324 Json(ErrorResponse {
325 error: format!("Session '{}' not found", session_id),
326 }),
327 )
328 .into_response();
329 }
330 Err(e) => {
331 return (
332 StatusCode::INTERNAL_SERVER_ERROR,
333 Json(ErrorResponse {
334 error: e.to_string(),
335 }),
336 )
337 .into_response();
338 }
339 }
340 }
341
342 // ── 2. Concurrency check — 409 if session already active ──────────────
343 {
344 let mut active = state.active_sessions.lock().await;
345 if active.contains(&session_id) {
346 return (
347 StatusCode::CONFLICT,
348 Json(ErrorResponse {
349 error: format!(
350 "Session '{}' already has an active agent execution",
351 session_id
352 ),
353 }),
354 )
355 .into_response();
356 }
357 active.insert(session_id.clone());
358 }
359
360 // ── 3. Persist the user message ───────────────────────────────────────
361 let content = body.content.clone();
362 {
363 let store = state.store.lock().await;
364 if let Err(e) = store.add_message(&session_id, &llm::Message::user(&content)) {
365 // Remove from active set on early failure.
366 state.active_sessions.lock().await.remove(&session_id);
367 return (
368 StatusCode::INTERNAL_SERVER_ERROR,
369 Json(ErrorResponse {
370 error: e.to_string(),
371 }),
372 )
373 .into_response();
374 }
375 }
376
377 // ── 4. Build HttpIO — agent will push SseEvents into the channel ──────
378 let (http_io, rx) = HttpIO::new();
379 let io: std::sync::Arc<dyn crate::io::AgentIO> = std::sync::Arc::new(http_io);
380
381 // Clone the config from AppState — AgentContext::new() takes ownership of
382 // these values and the task runs asynchronously so we need owned copies.
383 let config = state.config.clone();
384 let sid = session_id.clone();
385 let state2 = std::sync::Arc::clone(&state);
386
387 // ── 5. Spawn the agent loop in a background task ──────────────────────
388 //
389 // The task:
390 // a) Builds a fresh AgentContext (registers tools, optional MCP, etc.)
391 // b) Constructs the message list (system prompt + user message)
392 // c) Runs Director::execute() — the agent → tool loop
393 // d) Persists the final assistant message
394 // e) Sends SseEvent::Complete so the HTTP client knows the stream ended
395 // f) Removes the session from `active_sessions`
396 //
397 // The HTTP handler returns the SSE stream immediately (step below) and does
398 // NOT await the spawned task — the client receives events as they arrive.
399 tokio::spawn(async move {
400 // Build IO + AgentContext.
401 // We pass None for project/sandbox/model/provider/key — they are all
402 // read from `config` which was already loaded at serve startup.
403 let ctx_result = AgentContext::new(
404 config.project_dir.clone(), // project dir from config
405 false, // no_sandbox: read from config inside new()
406 Some(config.model.clone()),
407 Some(config.provider.api_base.clone()),
408 Some(config.provider.api_key.clone()),
409 config.agent.compact_mode,
410 std::sync::Arc::clone(&io),
411 )
412 .await;
413
414 let ctx = match ctx_result {
415 Ok(c) => c,
416 Err(e) => {
417 let _ = io.write_error(&format!("Agent init failed: {:#}", e)).await;
418 state2.active_sessions.lock().await.remove(&sid);
419 return;
420 }
421 };
422
423 // Build system prompt and initial messages.
424 let agents_md = crate::agent::agents_md::load_agents_md(&ctx.project_dir);
425 let coder = crate::agent::coder::CoderAgent::new_with_agents_md(
426 ctx.config.agent.clone(),
427 agents_md,
428 );
429 let mut messages = vec![
430 llm::Message::system(coder.system_prompt().as_str()),
431 llm::Message::user(content.as_str()),
432 ];
433
434 // Run the agent loop.
435 let director = Director::new(ctx.config.agent.clone());
436 let result = director
437 .execute(
438 &mut messages,
439 ctx.registry.as_ref(),
440 ctx.llm.as_ref(),
441 &ctx.tool_ctx,
442 )
443 .await;
444
445 // Persist result and send Complete event.
446 match result {
447 Ok(agent_result) => {
448 let store = state2.store.lock().await;
449 let _ = store.add_message(
450 &sid,
451 &llm::Message::assistant(Some(agent_result.final_message), None),
452 );
453 let _ = store.update_session_timestamp(&sid);
454 }
455 Err(e) => {
456 let _ = io.write_error(&format!("Agent error: {:#}", e)).await;
457 }
458 }
459
460 // Signal the SSE stream that the agent is done.
461 // The Sender is owned by HttpIO which is wrapped in `io` (Arc).
462 // Drop io before removing from active_sessions so the channel closes
463 // and the SSE stream terminates cleanly.
464 let _ = io.show_status("[DONE]").await;
465 drop(io);
466 state2.active_sessions.lock().await.remove(&sid);
467 });
468
469 // ── 6. Convert the mpsc Receiver into an axum SSE stream ─────────────
470 //
471 // `stream::unfold` turns our Receiver into an async Stream<Item=...>.
472 // Each received SseEvent is converted to an axum `Event` with the
473 // appropriate `event` name and JSON `data` field.
474 // When the channel closes (sender dropped), the stream ends naturally.
475 let sse_stream = stream::unfold(rx, |mut receiver| async move {
476 // recv() returns None when the channel is closed.
477 let event = receiver.recv().await?;
478
479 // Build the axum SSE Event.
480 let axum_event = Event::default()
481 .event(event.event_name())
482 .data(event.data_json());
483
484 Some((Ok::<Event, Infallible>(axum_event), receiver))
485 });
486
487 // Return the SSE response. KeepAlive sends periodic comments to prevent
488 // proxy/browser timeouts on long-running agent executions.
489 Sse::new(sse_stream)
490 .keep_alive(KeepAlive::default())
491 .into_response()
492}