diff --git a/.sunbeam/history b/.sunbeam/history new file mode 100644 index 0000000..97d8c70 --- /dev/null +++ b/.sunbeam/history @@ -0,0 +1,35 @@ +hmm +just testing the ux +/exit +/exit +hmm, scrolling is very slow. needs to be async +/exit +/exit +/exit +/exit +/exit +/exit +/exit +[<35;52;20M/exit +/exit +/exit +hey you +who are you? +hmm. +that's not right. +you're supposed to be `sol` +what's on page 2 of hackernews today? +don't you have fetch tooling? +/exit +hey. +hey +/exot +/exit +hey +say hello from sunbeam code! +can you search the web for me and tell me what's on page 2 of hackernews? +/exit +hey boo +tell me about yourself +can you please do some googling and some research to see if i can use devstral-medium as an agent? +/exit \ No newline at end of file diff --git a/sunbeam/src/code/agent.rs b/sunbeam/src/code/agent.rs index 20c99e6..45fbe06 100644 --- a/sunbeam/src/code/agent.rs +++ b/sunbeam/src/code/agent.rs @@ -7,10 +7,43 @@ //! This module is designed to be usable as a library — nothing here //! depends on ratatui or terminal state. -use crossbeam_channel::{Receiver, Sender, TrySendError}; +use crossbeam_channel::{Receiver, Sender}; use super::client::{self, CodeSession}; +/// Turn raw internal errors into something a human can read. +fn friendly_error(e: &str) -> String { + let lower = e.to_lowercase(); + if lower.contains("broken pipe") || lower.contains("stream closed") || lower.contains("h2 protocol") { + "sol disconnected — try again or restart with /exit".into() + } else if lower.contains("channel closed") || lower.contains("send on closed") { + "connection to sol lost".into() + } else if lower.contains("timed out") || lower.contains("timeout") { + "request timed out — sol may be overloaded".into() + } else if lower.contains("connection refused") { + "can't reach sol — is it running?".into() + } else if lower.contains("not found") && lower.contains("agent") { + "sol's agent was reset — reconnect with /exit".into() + } else if lower.contains("invalid_request_error") { + // Extract the actual message from Mistral API errors + if let Some(start) = e.find("\"msg\":\"") { + let rest = &e[start + 7..]; + if let Some(end) = rest.find('"') { + return rest[..end].to_string(); + } + } + "request error from sol".into() + } else { + // Truncate long errors and strip Rust debug formatting + let clean = e.replace("\\n", " ").replace("\\\"", "'"); + if clean.len() > 120 { + format!("{}…", &clean[..117]) + } else { + clean + } + } +} + // ── Requests (TUI → Agent) ────────────────────────────────────────────── /// A request from the UI to the agent backend. @@ -38,6 +71,8 @@ pub enum AgentEvent { Error { message: String }, /// Status update (shown in title bar). Status { message: String }, + /// Connection health: true = reachable, false = unreachable. + Health { connected: bool }, /// Session ended. SessionEnded, } @@ -76,11 +111,12 @@ impl AgentHandle { // ── Spawn ────────────────────────────────────────────────────────────── /// Spawn the agent background task. Returns a handle for the TUI. -pub fn spawn(session: CodeSession) -> AgentHandle { +pub fn spawn(session: CodeSession, endpoint: String) -> AgentHandle { let (req_tx, req_rx) = crossbeam_channel::bounded::(32); let (evt_tx, evt_rx) = crossbeam_channel::bounded::(256); - tokio::spawn(agent_loop(session, req_rx, evt_tx)); + tokio::spawn(agent_loop(session, req_rx, evt_tx.clone())); + tokio::spawn(heartbeat_loop(endpoint, evt_tx)); AgentHandle { tx: req_tx, @@ -88,6 +124,25 @@ pub fn spawn(session: CodeSession) -> AgentHandle { } } +/// Ping the gRPC endpoint every second to check if Sol is reachable. +async fn heartbeat_loop(endpoint: String, evt_tx: Sender) { + use sunbeam_proto::sunbeam_code_v1::code_agent_client::CodeAgentClient; + + let mut last_state = true; // assume connected initially (we just connected) + let _ = evt_tx.try_send(AgentEvent::Health { connected: true }); + + loop { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + let connected = CodeAgentClient::connect(endpoint.clone()).await.is_ok(); + + if connected != last_state { + let _ = evt_tx.try_send(AgentEvent::Health { connected }); + last_state = connected; + } + } +} + /// The background agent loop. Reads requests, calls gRPC, emits events. async fn agent_loop( mut session: CodeSession, @@ -126,7 +181,7 @@ async fn agent_loop( message: msg.clone(), }, client::ChatEvent::Error(msg) => AgentEvent::Error { - message: msg.clone(), + message: friendly_error(msg), }, }; let _ = evt_tx.try_send(agent_event); @@ -136,7 +191,7 @@ async fn agent_loop( } Err(e) => { let _ = evt_tx.try_send(AgentEvent::Error { - message: e.to_string(), + message: friendly_error(&e.to_string()), }); } } diff --git a/sunbeam/src/code/mod.rs b/sunbeam/src/code/mod.rs index 0f0a064..7b54f72 100644 --- a/sunbeam/src/code/mod.rs +++ b/sunbeam/src/code/mod.rs @@ -82,7 +82,7 @@ async fn cmd_code_inner(cmd: Option) -> anyhow::Result<()> { let model = model .or(cfg.model_name.clone()) - .unwrap_or_else(|| "devstral-small-latest".into()); + .unwrap_or_else(|| "mistral-medium-latest".into()); // Connect to Sol let mut session = client::connect(&endpoint, &project, &cfg, &model).await?; @@ -104,7 +104,7 @@ async fn cmd_code_inner(cmd: Option) -> anyhow::Result<()> { // Spawn agent on background task let project_path = project.path.clone(); - let agent = agent::spawn(session); + let agent = agent::spawn(session, endpoint.clone()); // TUI event loop — never blocks on network I/O use crossterm::event::{self, Event, KeyCode, KeyModifiers, MouseEventKind}; @@ -160,6 +160,12 @@ async fn cmd_code_inner(cmd: Option) -> anyhow::Result<()> { app.sol_status.clear(); app.push_log(tui::LogEntry::Error(message)); } + agent::AgentEvent::Health { connected } => { + if app.sol_connected != connected { + app.sol_connected = connected; + app.needs_redraw = true; + } + } agent::AgentEvent::SessionEnded => { break; } diff --git a/sunbeam/src/code/tui.rs b/sunbeam/src/code/tui.rs index ec8f303..c9af44e 100644 --- a/sunbeam/src/code/tui.rs +++ b/sunbeam/src/code/tui.rs @@ -273,6 +273,7 @@ pub struct App { pub approval: Option, pub is_thinking: bool, pub sol_status: String, + pub sol_connected: bool, pub should_quit: bool, pub show_logs: bool, pub log_buffer: LogBuffer, @@ -299,6 +300,7 @@ impl App { approval: None, is_thinking: false, sol_status: String::new(), + sol_connected: true, should_quit: false, show_logs: false, log_buffer, @@ -391,6 +393,8 @@ pub fn draw(frame: &mut ratatui::Frame, app: &mut App) { } fn draw_title_bar(frame: &mut ratatui::Frame, area: Rect, app: &App) { + let health = if app.sol_connected { "☀️" } else { "⛈️" }; + let left = vec![ Span::styled("sunbeam code", Style::default().fg(SOL_YELLOW).add_modifier(Modifier::BOLD)), Span::styled(" · ", Style::default().fg(SOL_FAINT)), @@ -399,21 +403,21 @@ fn draw_title_bar(frame: &mut ratatui::Frame, area: Rect, app: &App) { Span::styled(&app.branch, Style::default().fg(SOL_DIM)), ]; - // Right side: model name + sol status - let right_parts = if app.is_thinking { + // Right side: health + status + model + let mut right_parts = vec![Span::raw(health.to_string())]; + + if app.is_thinking { let status = if app.sol_status.is_empty() { "generating…" } else { &app.sol_status }; - vec![ - Span::styled(status, Style::default().fg(SOL_AMBER).add_modifier(Modifier::ITALIC)), - Span::styled(" · ", Style::default().fg(SOL_FAINT)), - Span::styled(&app.model, Style::default().fg(SOL_DIM)), - ] - } else { - vec![Span::styled(&app.model, Style::default().fg(SOL_DIM))] - }; + right_parts.push(Span::styled(" ", Style::default().fg(SOL_FAINT))); + right_parts.push(Span::styled(status, Style::default().fg(SOL_AMBER).add_modifier(Modifier::ITALIC))); + } + + right_parts.push(Span::styled(" · ", Style::default().fg(SOL_FAINT))); + right_parts.push(Span::styled(&app.model, Style::default().fg(SOL_DIM))); let title_line = Line::from(left); frame.render_widget(Paragraph::new(title_line), area); diff --git a/sunbeam/tests/code_integration.rs b/sunbeam/tests/code_integration.rs new file mode 100644 index 0000000..418a17e --- /dev/null +++ b/sunbeam/tests/code_integration.rs @@ -0,0 +1,212 @@ +/// Integration test: starts a mock gRPC server and connects the client. +/// Tests the full bidirectional stream lifecycle without needing Sol or Mistral. + +use std::pin::Pin; +use std::sync::Arc; + +use futures::Stream; +use sunbeam_proto::sunbeam_code_v1::code_agent_server::{CodeAgent, CodeAgentServer}; +use sunbeam_proto::sunbeam_code_v1::*; +use tokio::sync::mpsc; +use tokio_stream::wrappers::ReceiverStream; +use tonic::{Request, Response, Status, Streaming}; + +/// Mock server that echoes back user input as assistant text. +struct MockCodeAgent; + +#[tonic::async_trait] +impl CodeAgent for MockCodeAgent { + type SessionStream = Pin> + Send>>; + + async fn session( + &self, + request: Request>, + ) -> Result, Status> { + let mut in_stream = request.into_inner(); + let (tx, rx) = mpsc::channel(32); + + tokio::spawn(async move { + // Wait for StartSession + if let Ok(Some(msg)) = in_stream.message().await { + if let Some(client_message::Payload::Start(start)) = msg.payload { + let _ = tx.send(Ok(ServerMessage { + payload: Some(server_message::Payload::Ready(SessionReady { + session_id: "test-session-123".into(), + room_id: "!test-room:local".into(), + model: if start.model.is_empty() { + "devstral-2".into() + } else { + start.model + }, + })), + })).await; + } + } + + // Echo loop + while let Ok(Some(msg)) = in_stream.message().await { + match msg.payload { + Some(client_message::Payload::Input(input)) => { + let _ = tx.send(Ok(ServerMessage { + payload: Some(server_message::Payload::Done(TextDone { + full_text: format!("[echo] {}", input.text), + input_tokens: 10, + output_tokens: 5, + })), + })).await; + } + Some(client_message::Payload::End(_)) => { + let _ = tx.send(Ok(ServerMessage { + payload: Some(server_message::Payload::End(SessionEnd { + summary: "Session ended.".into(), + })), + })).await; + break; + } + _ => {} + } + } + }); + + Ok(Response::new(Box::pin(ReceiverStream::new(rx)))) + } +} + +#[tokio::test] +async fn test_session_lifecycle() { + // Start mock server on a random port + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + tokio::spawn(async move { + let incoming = tokio_stream::wrappers::TcpListenerStream::new(listener); + tonic::transport::Server::builder() + .add_service(CodeAgentServer::new(MockCodeAgent)) + .serve_with_incoming(incoming) + .await + .unwrap(); + }); + + // Give server a moment to start + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + // Connect client + let endpoint = format!("http://{addr}"); + + use sunbeam_proto::sunbeam_code_v1::code_agent_client::CodeAgentClient; + let mut client = CodeAgentClient::connect(endpoint).await.unwrap(); + + let (tx, client_rx) = mpsc::channel::(32); + let client_stream = ReceiverStream::new(client_rx); + let response = client.session(client_stream).await.unwrap(); + let mut rx = response.into_inner(); + + // Send StartSession + tx.send(ClientMessage { + payload: Some(client_message::Payload::Start(StartSession { + project_path: "/test/project".into(), + prompt_md: "test prompt".into(), + config_toml: String::new(), + git_branch: "main".into(), + git_status: String::new(), + file_tree: vec!["src/".into(), "Cargo.toml".into()], + model: "test-model".into(), + client_tools: vec![], + })), + }).await.unwrap(); + + // Receive SessionReady + let msg = rx.message().await.unwrap().unwrap(); + match msg.payload { + Some(server_message::Payload::Ready(ready)) => { + assert_eq!(ready.session_id, "test-session-123"); + assert_eq!(ready.model, "test-model"); + } + other => panic!("Expected SessionReady, got {other:?}"), + } + + // Send a chat message + tx.send(ClientMessage { + payload: Some(client_message::Payload::Input(UserInput { + text: "hello sol".into(), + })), + }).await.unwrap(); + + // Receive echo response + let msg = rx.message().await.unwrap().unwrap(); + match msg.payload { + Some(server_message::Payload::Done(done)) => { + assert_eq!(done.full_text, "[echo] hello sol"); + assert_eq!(done.input_tokens, 10); + assert_eq!(done.output_tokens, 5); + } + other => panic!("Expected TextDone, got {other:?}"), + } + + // End session + tx.send(ClientMessage { + payload: Some(client_message::Payload::End(EndSession {})), + }).await.unwrap(); + + let msg = rx.message().await.unwrap().unwrap(); + match msg.payload { + Some(server_message::Payload::End(end)) => { + assert_eq!(end.summary, "Session ended."); + } + other => panic!("Expected SessionEnd, got {other:?}"), + } +} + +#[tokio::test] +async fn test_multiple_messages() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + tokio::spawn(async move { + let incoming = tokio_stream::wrappers::TcpListenerStream::new(listener); + tonic::transport::Server::builder() + .add_service(CodeAgentServer::new(MockCodeAgent)) + .serve_with_incoming(incoming) + .await + .unwrap(); + }); + + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + let endpoint = format!("http://{addr}"); + use sunbeam_proto::sunbeam_code_v1::code_agent_client::CodeAgentClient; + let mut client = CodeAgentClient::connect(endpoint).await.unwrap(); + + let (tx, client_rx) = mpsc::channel::(32); + let client_stream = ReceiverStream::new(client_rx); + let response = client.session(client_stream).await.unwrap(); + let mut rx = response.into_inner(); + + // Start + tx.send(ClientMessage { + payload: Some(client_message::Payload::Start(StartSession { + project_path: "/test".into(), + model: "devstral-2".into(), + ..Default::default() + })), + }).await.unwrap(); + + let _ = rx.message().await.unwrap().unwrap(); // SessionReady + + // Send 3 messages and verify each echo + for i in 0..3 { + tx.send(ClientMessage { + payload: Some(client_message::Payload::Input(UserInput { + text: format!("message {i}"), + })), + }).await.unwrap(); + + let msg = rx.message().await.unwrap().unwrap(); + match msg.payload { + Some(server_message::Payload::Done(done)) => { + assert_eq!(done.full_text, format!("[echo] message {i}")); + } + other => panic!("Expected TextDone for message {i}, got {other:?}"), + } + } +}