feat(code): friendly errors, batch history, persistent command history
- Agent errors sanitized: raw hyper/h2/gRPC dumps replaced with
human-readable messages ("sol disconnected", "connection lost", etc.)
- Batch history loading: single viewport rebuild instead of per-entry
- Persistent command history: saved to .sunbeam/history, loaded on start
- Default model: mistral-medium-latest (personality adherence)
This commit is contained in:
35
.sunbeam/history
Normal file
35
.sunbeam/history
Normal file
@@ -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
|
||||||
@@ -7,10 +7,43 @@
|
|||||||
//! This module is designed to be usable as a library — nothing here
|
//! This module is designed to be usable as a library — nothing here
|
||||||
//! depends on ratatui or terminal state.
|
//! depends on ratatui or terminal state.
|
||||||
|
|
||||||
use crossbeam_channel::{Receiver, Sender, TrySendError};
|
use crossbeam_channel::{Receiver, Sender};
|
||||||
|
|
||||||
use super::client::{self, CodeSession};
|
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) ──────────────────────────────────────────────
|
// ── Requests (TUI → Agent) ──────────────────────────────────────────────
|
||||||
|
|
||||||
/// A request from the UI to the agent backend.
|
/// A request from the UI to the agent backend.
|
||||||
@@ -38,6 +71,8 @@ pub enum AgentEvent {
|
|||||||
Error { message: String },
|
Error { message: String },
|
||||||
/// Status update (shown in title bar).
|
/// Status update (shown in title bar).
|
||||||
Status { message: String },
|
Status { message: String },
|
||||||
|
/// Connection health: true = reachable, false = unreachable.
|
||||||
|
Health { connected: bool },
|
||||||
/// Session ended.
|
/// Session ended.
|
||||||
SessionEnded,
|
SessionEnded,
|
||||||
}
|
}
|
||||||
@@ -76,11 +111,12 @@ impl AgentHandle {
|
|||||||
// ── Spawn ──────────────────────────────────────────────────────────────
|
// ── Spawn ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Spawn the agent background task. Returns a handle for the TUI.
|
/// 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::<AgentRequest>(32);
|
let (req_tx, req_rx) = crossbeam_channel::bounded::<AgentRequest>(32);
|
||||||
let (evt_tx, evt_rx) = crossbeam_channel::bounded::<AgentEvent>(256);
|
let (evt_tx, evt_rx) = crossbeam_channel::bounded::<AgentEvent>(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 {
|
AgentHandle {
|
||||||
tx: req_tx,
|
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<AgentEvent>) {
|
||||||
|
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.
|
/// The background agent loop. Reads requests, calls gRPC, emits events.
|
||||||
async fn agent_loop(
|
async fn agent_loop(
|
||||||
mut session: CodeSession,
|
mut session: CodeSession,
|
||||||
@@ -126,7 +181,7 @@ async fn agent_loop(
|
|||||||
message: msg.clone(),
|
message: msg.clone(),
|
||||||
},
|
},
|
||||||
client::ChatEvent::Error(msg) => AgentEvent::Error {
|
client::ChatEvent::Error(msg) => AgentEvent::Error {
|
||||||
message: msg.clone(),
|
message: friendly_error(msg),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let _ = evt_tx.try_send(agent_event);
|
let _ = evt_tx.try_send(agent_event);
|
||||||
@@ -136,7 +191,7 @@ async fn agent_loop(
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = evt_tx.try_send(AgentEvent::Error {
|
let _ = evt_tx.try_send(AgentEvent::Error {
|
||||||
message: e.to_string(),
|
message: friendly_error(&e.to_string()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ async fn cmd_code_inner(cmd: Option<CodeCommand>) -> anyhow::Result<()> {
|
|||||||
|
|
||||||
let model = model
|
let model = model
|
||||||
.or(cfg.model_name.clone())
|
.or(cfg.model_name.clone())
|
||||||
.unwrap_or_else(|| "devstral-small-latest".into());
|
.unwrap_or_else(|| "mistral-medium-latest".into());
|
||||||
|
|
||||||
// Connect to Sol
|
// Connect to Sol
|
||||||
let mut session = client::connect(&endpoint, &project, &cfg, &model).await?;
|
let mut session = client::connect(&endpoint, &project, &cfg, &model).await?;
|
||||||
@@ -104,7 +104,7 @@ async fn cmd_code_inner(cmd: Option<CodeCommand>) -> anyhow::Result<()> {
|
|||||||
|
|
||||||
// Spawn agent on background task
|
// Spawn agent on background task
|
||||||
let project_path = project.path.clone();
|
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
|
// TUI event loop — never blocks on network I/O
|
||||||
use crossterm::event::{self, Event, KeyCode, KeyModifiers, MouseEventKind};
|
use crossterm::event::{self, Event, KeyCode, KeyModifiers, MouseEventKind};
|
||||||
@@ -160,6 +160,12 @@ async fn cmd_code_inner(cmd: Option<CodeCommand>) -> anyhow::Result<()> {
|
|||||||
app.sol_status.clear();
|
app.sol_status.clear();
|
||||||
app.push_log(tui::LogEntry::Error(message));
|
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 => {
|
agent::AgentEvent::SessionEnded => {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -273,6 +273,7 @@ pub struct App {
|
|||||||
pub approval: Option<ApprovalPrompt>,
|
pub approval: Option<ApprovalPrompt>,
|
||||||
pub is_thinking: bool,
|
pub is_thinking: bool,
|
||||||
pub sol_status: String,
|
pub sol_status: String,
|
||||||
|
pub sol_connected: bool,
|
||||||
pub should_quit: bool,
|
pub should_quit: bool,
|
||||||
pub show_logs: bool,
|
pub show_logs: bool,
|
||||||
pub log_buffer: LogBuffer,
|
pub log_buffer: LogBuffer,
|
||||||
@@ -299,6 +300,7 @@ impl App {
|
|||||||
approval: None,
|
approval: None,
|
||||||
is_thinking: false,
|
is_thinking: false,
|
||||||
sol_status: String::new(),
|
sol_status: String::new(),
|
||||||
|
sol_connected: true,
|
||||||
should_quit: false,
|
should_quit: false,
|
||||||
show_logs: false,
|
show_logs: false,
|
||||||
log_buffer,
|
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) {
|
fn draw_title_bar(frame: &mut ratatui::Frame, area: Rect, app: &App) {
|
||||||
|
let health = if app.sol_connected { "☀️" } else { "⛈️" };
|
||||||
|
|
||||||
let left = vec![
|
let left = vec![
|
||||||
Span::styled("sunbeam code", Style::default().fg(SOL_YELLOW).add_modifier(Modifier::BOLD)),
|
Span::styled("sunbeam code", Style::default().fg(SOL_YELLOW).add_modifier(Modifier::BOLD)),
|
||||||
Span::styled(" · ", Style::default().fg(SOL_FAINT)),
|
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)),
|
Span::styled(&app.branch, Style::default().fg(SOL_DIM)),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Right side: model name + sol status
|
// Right side: health + status + model
|
||||||
let right_parts = if app.is_thinking {
|
let mut right_parts = vec![Span::raw(health.to_string())];
|
||||||
|
|
||||||
|
if app.is_thinking {
|
||||||
let status = if app.sol_status.is_empty() {
|
let status = if app.sol_status.is_empty() {
|
||||||
"generating…"
|
"generating…"
|
||||||
} else {
|
} else {
|
||||||
&app.sol_status
|
&app.sol_status
|
||||||
};
|
};
|
||||||
vec![
|
right_parts.push(Span::styled(" ", Style::default().fg(SOL_FAINT)));
|
||||||
Span::styled(status, Style::default().fg(SOL_AMBER).add_modifier(Modifier::ITALIC)),
|
right_parts.push(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)),
|
|
||||||
]
|
right_parts.push(Span::styled(" · ", Style::default().fg(SOL_FAINT)));
|
||||||
} else {
|
right_parts.push(Span::styled(&app.model, Style::default().fg(SOL_DIM)));
|
||||||
vec![Span::styled(&app.model, Style::default().fg(SOL_DIM))]
|
|
||||||
};
|
|
||||||
|
|
||||||
let title_line = Line::from(left);
|
let title_line = Line::from(left);
|
||||||
frame.render_widget(Paragraph::new(title_line), area);
|
frame.render_widget(Paragraph::new(title_line), area);
|
||||||
|
|||||||
212
sunbeam/tests/code_integration.rs
Normal file
212
sunbeam/tests/code_integration.rs
Normal file
@@ -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<Box<dyn Stream<Item = Result<ServerMessage, Status>> + Send>>;
|
||||||
|
|
||||||
|
async fn session(
|
||||||
|
&self,
|
||||||
|
request: Request<Streaming<ClientMessage>>,
|
||||||
|
) -> Result<Response<Self::SessionStream>, 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::<ClientMessage>(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::<ClientMessage>(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:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user