From 2b506de2ec488f279b6d3764551596e97be938c6 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Wed, 4 Feb 2026 14:37:04 -0800 Subject: [PATCH] feat: implement session summarize/todo endpoints and tests --- .turbo | 1 + dist | 1 + node_modules | 1 + .../sandbox-agent/src/opencode_compat.rs | 98 +++- server/packages/sandbox-agent/src/router.rs | 499 ++++++++++++++++++ .../tests/opencode-compat/session.test.ts | 39 ++ target | 1 + 7 files changed, 636 insertions(+), 4 deletions(-) create mode 120000 .turbo create mode 120000 dist create mode 120000 node_modules create mode 120000 target diff --git a/.turbo b/.turbo new file mode 120000 index 0000000..0b7d9ca --- /dev/null +++ b/.turbo @@ -0,0 +1 @@ +/home/nathan/sandbox-agent/.turbo \ No newline at end of file diff --git a/dist b/dist new file mode 120000 index 0000000..f02d77f --- /dev/null +++ b/dist @@ -0,0 +1 @@ +/home/nathan/sandbox-agent/dist \ No newline at end of file diff --git a/node_modules b/node_modules new file mode 120000 index 0000000..501480b --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +/home/nathan/sandbox-agent/node_modules \ No newline at end of file diff --git a/server/packages/sandbox-agent/src/opencode_compat.rs b/server/packages/sandbox-agent/src/opencode_compat.rs index 55b7050..a984a7c 100644 --- a/server/packages/sandbox-agent/src/opencode_compat.rs +++ b/server/packages/sandbox-agent/src/opencode_compat.rs @@ -23,7 +23,10 @@ use tokio::sync::{broadcast, Mutex}; use tokio::time::interval; use utoipa::{IntoParams, OpenApi, ToSchema}; -use crate::router::{AppState, CreateSessionRequest, PermissionReply}; +use crate::router::{ + AppState, CreateSessionRequest, PermissionReply, SummaryGenerationRequest, + TodoGenerationRequest, SessionTodoItem, +}; use sandbox_agent_error::SandboxError; use sandbox_agent_agent_management::agents::AgentId; use sandbox_agent_universal_agent_schema::{ @@ -653,6 +656,21 @@ async fn resolve_session_agent( ) } +fn model_override_for_agent( + provider_id: &str, + model_id: &str, + agent: AgentId, +) -> Option { + if agent == AgentId::Mock { + return None; + } + if provider_id == OPENCODE_PROVIDER_ID && model_id == agent.as_str() { + None + } else { + Some(model_id.to_string()) + } +} + fn agent_display_name(agent: AgentId) -> &'static str { match agent { AgentId::Claude => "Claude", @@ -2842,12 +2860,44 @@ async fn oc_session_diff() -> impl IntoResponse { tag = "opencode" )] async fn oc_session_summarize( + State(state): State>, + Path(session_id): Path, Json(body): Json, ) -> impl IntoResponse { if body.provider_id.is_none() || body.model_id.is_none() { return bad_request("providerID and modelID are required"); } - bool_ok(true) + let provider_id = body.provider_id.unwrap_or_default(); + let model_id = body.model_id.unwrap_or_default(); + + let sessions = state.opencode.sessions.lock().await; + if !sessions.contains_key(&session_id) { + return not_found("Session not found"); + } + drop(sessions); + + let (agent_id, resolved_provider, resolved_model) = + resolve_session_agent(&state, &session_id, Some(&provider_id), Some(&model_id)).await; + if let Err(err) = ensure_backing_session(&state, &session_id, &agent_id).await { + return sandbox_error_response(err); + } + let agent = AgentId::parse(&agent_id).unwrap_or_else(default_agent_id); + let request = SummaryGenerationRequest { + agent, + provider_id: resolved_provider.clone(), + model_id: resolved_model.clone(), + model: model_override_for_agent(&resolved_provider, &resolved_model, agent), + }; + + match state + .inner + .session_manager() + .summarize_session(&session_id, request) + .await + { + Ok(_) => bool_ok(true), + Err(err) => sandbox_error_response(err), + } } #[utoipa::path( @@ -3344,8 +3394,48 @@ async fn oc_session_unshare( responses((status = 200)), tag = "opencode" )] -async fn oc_session_todo() -> impl IntoResponse { - (StatusCode::OK, Json(json!([]))) +async fn oc_session_todo( + State(state): State>, + Path(session_id): Path, +) -> impl IntoResponse { + let sessions = state.opencode.sessions.lock().await; + if !sessions.contains_key(&session_id) { + return not_found("Session not found").into_response(); + } + drop(sessions); + + let (agent_id, resolved_provider, resolved_model) = + resolve_session_agent(&state, &session_id, None, None).await; + if let Err(err) = ensure_backing_session(&state, &session_id, &agent_id).await { + return sandbox_error_response(err).into_response(); + } + let agent = AgentId::parse(&agent_id).unwrap_or_else(default_agent_id); + let request = TodoGenerationRequest { + agent, + provider_id: resolved_provider.clone(), + model_id: resolved_model.clone(), + model: model_override_for_agent(&resolved_provider, &resolved_model, agent), + }; + + match state + .inner + .session_manager() + .session_todo(&session_id, request) + .await + { + Ok((artifact, created)) => { + let todos: Vec = + artifact.items.iter().map(SessionTodoItem::to_value).collect(); + if created { + state.opencode.emit_event(json!({ + "type": "todo.updated", + "properties": {"sessionID": session_id, "todos": todos.clone()} + })); + } + (StatusCode::OK, Json(json!(todos))).into_response() + } + Err(err) => sandbox_error_response(err).into_response(), + } } #[utoipa::path( diff --git a/server/packages/sandbox-agent/src/router.rs b/server/packages/sandbox-agent/src/router.rs index 3ca437a..969235f 100644 --- a/server/packages/sandbox-agent/src/router.rs +++ b/server/packages/sandbox-agent/src/router.rs @@ -331,6 +331,10 @@ struct SessionState { next_event_sequence: u64, next_item_id: u64, events: Vec, + summary_artifacts: Vec, + todo_artifacts: Vec, + next_summary_version: u64, + next_todo_version: u64, pending_questions: HashMap, pending_permissions: HashMap, item_started: HashSet, @@ -348,6 +352,38 @@ struct SessionState { pending_assistant_counter: u64, } +#[derive(Debug, Clone)] +pub(crate) struct SessionSummaryArtifact { + pub version: u64, + pub created_at: i64, + pub provider_id: String, + pub model_id: String, + pub summary: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct SessionTodoItem { + pub content: String, + pub status: String, + pub priority: String, + pub id: String, +} + +impl SessionTodoItem { + pub(crate) fn to_value(&self) -> Value { + serde_json::to_value(self).unwrap_or_else(|_| json!({})) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct SessionTodoArtifact { + pub version: u64, + pub created_at: i64, + pub provider_id: String, + pub model_id: String, + pub items: Vec, +} + #[derive(Debug, Clone)] struct PendingPermission { action: String, @@ -389,6 +425,10 @@ impl SessionState { next_event_sequence: 0, next_item_id: 0, events: Vec::new(), + summary_artifacts: Vec::new(), + todo_artifacts: Vec::new(), + next_summary_version: 1, + next_todo_version: 1, pending_questions: HashMap::new(), pending_permissions: HashMap::new(), item_started: HashSet::new(), @@ -820,6 +860,22 @@ pub(crate) struct SessionManager { http_client: Client, } +#[derive(Debug, Clone)] +pub(crate) struct SummaryGenerationRequest { + pub agent: AgentId, + pub provider_id: String, + pub model_id: String, + pub model: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct TodoGenerationRequest { + pub agent: AgentId, + pub provider_id: String, + pub model_id: String, + pub model: Option, +} + /// Shared Codex app-server process that handles multiple sessions via JSON-RPC. /// Similar to OpenCode's server model - a single long-running process that multiplexes /// multiple thread (session) conversations. @@ -1772,6 +1828,171 @@ impl SessionManager { Ok(()) } + pub(crate) async fn summarize_session( + self: &Arc, + session_id: &str, + request: SummaryGenerationRequest, + ) -> Result { + let (snapshot, transcript) = { + let sessions = self.sessions.lock().await; + let session = Self::session_ref(&sessions, session_id).ok_or_else(|| { + SandboxError::SessionNotFound { + session_id: session_id.to_string(), + } + })?; + let transcript = session_transcript(&session.events); + ( + SessionSnapshot { + session_id: session.session_id.clone(), + agent: request.agent, + agent_mode: session.agent_mode.clone(), + permission_mode: session.permission_mode.clone(), + model: request.model.clone(), + variant: session.variant.clone(), + native_session_id: None, + }, + transcript, + ) + }; + + let prompt = summary_prompt(&transcript); + let mut summary = if request.agent == AgentId::Mock { + mock_summary(&transcript) + } else { + self.run_prompt(snapshot, request.agent, prompt) + .await? + .unwrap_or_default() + }; + summary = summary.trim().to_string(); + if summary.is_empty() { + summary = mock_summary(&transcript); + } + + let artifact = { + let mut sessions = self.sessions.lock().await; + let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| { + SandboxError::SessionNotFound { + session_id: session_id.to_string(), + } + })?; + let version = session.next_summary_version; + session.next_summary_version += 1; + let artifact = SessionSummaryArtifact { + version, + created_at: now_ms(), + provider_id: request.provider_id, + model_id: request.model_id, + summary, + }; + session.summary_artifacts.push(artifact.clone()); + artifact + }; + + Ok(artifact) + } + + pub(crate) async fn session_todo( + self: &Arc, + session_id: &str, + request: TodoGenerationRequest, + ) -> Result<(SessionTodoArtifact, bool), SandboxError> { + let existing = { + let sessions = self.sessions.lock().await; + let session = Self::session_ref(&sessions, session_id).ok_or_else(|| { + SandboxError::SessionNotFound { + session_id: session_id.to_string(), + } + })?; + session.todo_artifacts.last().cloned() + }; + if let Some(artifact) = existing { + return Ok((artifact, false)); + } + + let (snapshot, transcript) = { + let sessions = self.sessions.lock().await; + let session = Self::session_ref(&sessions, session_id).ok_or_else(|| { + SandboxError::SessionNotFound { + session_id: session_id.to_string(), + } + })?; + let transcript = session_transcript(&session.events); + ( + SessionSnapshot { + session_id: session.session_id.clone(), + agent: request.agent, + agent_mode: session.agent_mode.clone(), + permission_mode: session.permission_mode.clone(), + model: request.model.clone(), + variant: session.variant.clone(), + native_session_id: None, + }, + transcript, + ) + }; + + let prompt = todo_prompt(&transcript); + let mut items = if request.agent == AgentId::Mock { + mock_todos(&transcript) + } else { + let output = self + .run_prompt(snapshot, request.agent, prompt) + .await? + .unwrap_or_default(); + todo_items_from_output(&output) + }; + if items.is_empty() { + items = mock_todos(&transcript); + } + + let artifact = { + let mut sessions = self.sessions.lock().await; + let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| { + SandboxError::SessionNotFound { + session_id: session_id.to_string(), + } + })?; + let version = session.next_todo_version; + session.next_todo_version += 1; + let artifact = SessionTodoArtifact { + version, + created_at: now_ms(), + provider_id: request.provider_id, + model_id: request.model_id, + items, + }; + session.todo_artifacts.push(artifact.clone()); + artifact + }; + + Ok((artifact, true)) + } + + async fn run_prompt( + &self, + session: SessionSnapshot, + agent: AgentId, + prompt: String, + ) -> Result, SandboxError> { + let manager = self.agent_manager.clone(); + let credentials = tokio::task::spawn_blocking(move || { + let options = CredentialExtractionOptions::new(); + extract_all_credentials(&options) + }) + .await + .map_err(|err| SandboxError::StreamError { + message: err.to_string(), + })?; + let spawn_options = build_spawn_options(&session, prompt, credentials); + let spawn_result = tokio::task::spawn_blocking(move || manager.spawn(agent, spawn_options)) + .await + .map_err(|err| SandboxError::StreamError { + message: err.to_string(), + })?; + let spawn_result = spawn_result.map_err(|err| map_spawn_error(agent, err))?; + Ok(spawn_result.result) + } + async fn emit_synthetic_assistant_start(&self, session_id: &str) -> Result<(), SandboxError> { let conversion = { let mut sessions = self.sessions.lock().await; @@ -4093,6 +4314,284 @@ fn agent_supports_resume(agent: AgentId) -> bool { ) } +#[derive(Clone, Debug)] +struct TranscriptEntry { + role: ItemRole, + text: String, +} + +fn session_transcript(events: &[UniversalEvent]) -> Vec { + let mut entries = Vec::new(); + for event in events { + if event.event_type != UniversalEventType::ItemCompleted { + continue; + } + let UniversalEventData::Item(data) = &event.data else { + continue; + }; + let item = &data.item; + if item.kind != ItemKind::Message && item.kind != ItemKind::System { + continue; + } + let role = if let Some(role) = &item.role { + role.clone() + } else if item.kind == ItemKind::System { + ItemRole::System + } else { + continue; + }; + let text = content_parts_to_text(&item.content); + let text = text.trim(); + if text.is_empty() { + continue; + } + entries.push(TranscriptEntry { + role, + text: text.to_string(), + }); + } + entries +} + +fn content_parts_to_text(parts: &[ContentPart]) -> String { + let mut segments = Vec::new(); + for part in parts { + match part { + ContentPart::Text { text } => segments.push(text.clone()), + ContentPart::Json { json } => segments.push(json.to_string()), + ContentPart::ToolCall { + name, arguments, .. + } => segments.push(format!("Tool call {name}({arguments})")), + ContentPart::ToolResult { output, .. } => { + segments.push(format!("Tool result: {output}")) + } + ContentPart::FileRef { path, action, .. } => { + segments.push(format!("File {action:?}: {path}")) + } + ContentPart::Status { label, detail } => match detail { + Some(detail) => segments.push(format!("Status: {label} ({detail})")), + None => segments.push(format!("Status: {label}")), + }, + ContentPart::Reasoning { .. } | ContentPart::Image { .. } => {} + } + } + segments.join("\n") +} + +fn role_label(role: &ItemRole) -> &'static str { + match role { + ItemRole::User => "User", + ItemRole::Assistant => "Assistant", + ItemRole::System => "System", + ItemRole::Tool => "Tool", + } +} + +fn render_transcript(entries: &[TranscriptEntry]) -> String { + entries + .iter() + .map(|entry| format!("{}: {}", role_label(&entry.role), entry.text)) + .collect::>() + .join("\n") +} + +fn summary_prompt(entries: &[TranscriptEntry]) -> String { + let transcript = render_transcript(entries); + format!( + "Summarize the session in 3-6 concise sentences. Focus on goals, decisions, and outcomes.\n\nSession transcript:\n{transcript}\n" + ) +} + +fn todo_prompt(entries: &[TranscriptEntry]) -> String { + let transcript = render_transcript(entries); + format!( + "Create a JSON array of todo items from the session. Each item must include: content, status (pending|in_progress|completed|cancelled), priority (high|medium|low), and id. Return only JSON.\n\nSession transcript:\n{transcript}\n" + ) +} + +fn mock_summary(entries: &[TranscriptEntry]) -> String { + if entries.is_empty() { + return "No session activity to summarize.".to_string(); + } + let last_user = entries + .iter() + .rev() + .find(|entry| matches!(&entry.role, ItemRole::User)); + let last_assistant = entries + .iter() + .rev() + .find(|entry| matches!(&entry.role, ItemRole::Assistant)); + let mut summary = String::new(); + summary.push_str(&format!("Session contained {} messages.", entries.len())); + if let Some(entry) = last_user { + summary.push_str(" Latest user request: "); + summary.push_str(&truncate_text(&entry.text, 140)); + summary.push('.'); + } + if let Some(entry) = last_assistant { + summary.push_str(" Latest assistant response: "); + summary.push_str(&truncate_text(&entry.text, 140)); + summary.push('.'); + } + summary +} + +fn mock_todos(entries: &[TranscriptEntry]) -> Vec { + let mut items = Vec::new(); + for entry in entries { + for line in entry.text.lines() { + let trimmed = line.trim(); + let candidate = if let Some(rest) = trimmed.strip_prefix("TODO:") { + Some(rest) + } else if let Some(rest) = trimmed.strip_prefix("Todo:") { + Some(rest) + } else if let Some(rest) = trimmed.strip_prefix("todo:") { + Some(rest) + } else if let Some(rest) = trimmed.strip_prefix("- [ ]") { + Some(rest) + } else if let Some(rest) = trimmed.strip_prefix("* [ ]") { + Some(rest) + } else { + None + }; + if let Some(rest) = candidate { + let content = rest.trim(); + if !content.is_empty() { + items.push(SessionTodoItem { + content: content.to_string(), + status: "pending".to_string(), + priority: "medium".to_string(), + id: String::new(), + }); + } + } + } + } + + if items.is_empty() { + if let Some(entry) = entries + .iter() + .rev() + .find(|entry| matches!(&entry.role, ItemRole::User)) + { + let content = format!("Follow up on: {}", truncate_text(&entry.text, 140)); + items.push(SessionTodoItem { + content, + status: "pending".to_string(), + priority: "medium".to_string(), + id: String::new(), + }); + } + } + + for (index, item) in items.iter_mut().enumerate() { + if item.id.is_empty() { + item.id = format!("todo_{}", index + 1); + } + } + items +} + +fn todo_items_from_output(output: &str) -> Vec { + let trimmed = output.trim(); + if trimmed.is_empty() { + return Vec::new(); + } + let mut value = serde_json::from_str::(trimmed).ok(); + if value.is_none() { + if let (Some(start), Some(end)) = (trimmed.find('['), trimmed.rfind(']')) { + if start < end { + value = serde_json::from_str::(&trimmed[start..=end]).ok(); + } + } + } + let value = match value { + Some(value) => value, + None => return Vec::new(), + }; + let items = if let Some(array) = value.as_array() { + array.clone() + } else if let Some(array) = value.get("todos").and_then(Value::as_array) { + array.clone() + } else { + return Vec::new(); + }; + normalize_todo_items(&items) +} + +fn normalize_todo_items(items: &[Value]) -> Vec { + let mut todos = Vec::new(); + for (index, item) in items.iter().enumerate() { + let Some(obj) = item.as_object() else { + continue; + }; + let content = obj + .get("content") + .and_then(Value::as_str) + .unwrap_or("") + .trim() + .to_string(); + if content.is_empty() { + continue; + } + let status = obj + .get("status") + .and_then(Value::as_str) + .unwrap_or("pending"); + let priority = obj + .get("priority") + .and_then(Value::as_str) + .unwrap_or("medium"); + let id = obj.get("id").and_then(Value::as_str).unwrap_or(""); + + todos.push(SessionTodoItem { + content, + status: normalize_todo_status(status).to_string(), + priority: normalize_todo_priority(priority).to_string(), + id: if id.is_empty() { + format!("todo_{}", index + 1) + } else { + id.to_string() + }, + }); + } + todos +} + +fn normalize_todo_status(status: &str) -> &'static str { + match status.trim().to_ascii_lowercase().as_str() { + "pending" => "pending", + "in_progress" => "in_progress", + "completed" => "completed", + "cancelled" | "canceled" => "cancelled", + _ => "pending", + } +} + +fn normalize_todo_priority(priority: &str) -> &'static str { + match priority.trim().to_ascii_lowercase().as_str() { + "high" => "high", + "medium" => "medium", + "low" => "low", + _ => "medium", + } +} + +fn truncate_text(text: &str, max_len: usize) -> String { + if text.len() <= max_len { + return text.to_string(); + } + let truncated: String = text.chars().take(max_len).collect(); + format!("{}...", truncated.trim_end()) +} + +fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as i64) + .unwrap_or(0) +} + fn agent_supports_item_started(agent: AgentId) -> bool { agent_capabilities_for(agent).item_started } diff --git a/server/packages/sandbox-agent/tests/opencode-compat/session.test.ts b/server/packages/sandbox-agent/tests/opencode-compat/session.test.ts index c778691..5ebb607 100644 --- a/server/packages/sandbox-agent/tests/opencode-compat/session.test.ts +++ b/server/packages/sandbox-agent/tests/opencode-compat/session.test.ts @@ -145,4 +145,43 @@ describe("OpenCode-compatible Session API", () => { expect(response.data?.title).toBe("Keep"); }); }); + + describe("session.summarize + session.todo", () => { + it("should generate a summary and todo items", async () => { + const created = await client.session.create(); + const sessionId = created.data?.id!; + + await client.session.prompt({ + path: { id: sessionId }, + body: { + model: { providerID: "sandbox-agent", modelID: "mock" }, + parts: [ + { + type: "text", + text: "TODO: update summarize endpoint\nTODO: add todo list tests", + }, + ], + }, + }); + + const summarize = await client.session.summarize({ + path: { id: sessionId }, + body: { providerID: "sandbox-agent", modelID: "mock" }, + }); + expect(summarize.data).toBe(true); + + const todos = await client.session.todo({ path: { id: sessionId } }); + expect(Array.isArray(todos.data)).toBe(true); + expect(todos.data?.length).toBeGreaterThan(0); + + const first = todos.data?.[0]; + expect(first?.id).toBeDefined(); + expect(first?.content).toBeDefined(); + expect(first?.status).toBeDefined(); + expect(first?.priority).toBeDefined(); + + const contents = (todos.data ?? []).map((item) => item.content).join(" "); + expect(contents).toContain("summarize endpoint"); + }); + }); }); diff --git a/target b/target new file mode 120000 index 0000000..3d6ad8c --- /dev/null +++ b/target @@ -0,0 +1 @@ +/home/nathan/sandbox-agent/target \ No newline at end of file