Separate SSE streaming from POST work submission (#7834)

This commit is contained in:
jh-block
2026-03-18 18:00:06 +01:00
committed by GitHub
parent c2d10cea9b
commit 8d72344602
15 changed files with 1830 additions and 204 deletions
+1
View File
@@ -3,6 +3,7 @@ pub mod configuration;
pub mod error;
pub mod openapi;
pub mod routes;
pub mod session_event_bus;
pub mod state;
pub mod tls;
pub mod tunnel;
+1
View File
@@ -4,6 +4,7 @@ mod error;
mod logging;
mod openapi;
mod routes;
mod session_event_bus;
mod state;
mod tunnel;
+6
View File
@@ -435,6 +435,9 @@ derive_utoipa!(Icon as IconSchema);
super::routes::agent::update_session,
super::routes::action_required::confirm_tool_action,
super::routes::reply::reply,
super::routes::session_events::session_events,
super::routes::session_events::session_reply,
super::routes::session_events::session_cancel,
super::routes::session::list_sessions,
super::routes::session::search_sessions,
super::routes::session::get_session,
@@ -523,6 +526,9 @@ derive_utoipa!(Icon as IconSchema);
goose::prompt_template::Template,
super::routes::action_required::ConfirmToolActionRequest,
super::routes::reply::ChatRequest,
super::routes::session_events::SessionReplyRequest,
super::routes::session_events::SessionReplyResponse,
super::routes::session_events::CancelRequest,
super::routes::session::ImportSessionRequest,
super::routes::session::SessionListResponse,
super::routes::session::UpdateSessionNameRequest,
+2
View File
@@ -14,6 +14,7 @@ pub mod reply;
pub mod sampling;
pub mod schedule;
pub mod session;
pub mod session_events;
pub mod setup;
pub mod status;
pub mod telemetry;
@@ -44,5 +45,6 @@ pub fn configure(state: Arc<crate::state::AppState>, secret_key: String) -> Rout
.merge(gateway::routes(state.clone()))
.merge(mcp_ui_proxy::routes(secret_key.clone()))
.merge(mcp_app_proxy::routes(secret_key))
.merge(session_events::routes(state.clone()))
.merge(sampling::routes(state))
}
+8 -3
View File
@@ -29,7 +29,7 @@ use tokio::time::timeout;
use tokio_stream::wrappers::ReceiverStream;
use tokio_util::sync::CancellationToken;
fn track_tool_telemetry(content: &MessageContent, all_messages: &[Message]) {
pub fn track_tool_telemetry(content: &MessageContent, all_messages: &[Message]) {
match content {
MessageContent::ToolRequest(tool_request) => {
if let Ok(tool_call) = &tool_request.tool_call {
@@ -123,7 +123,7 @@ impl IntoResponse for SseResponse {
}
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
#[serde(tag = "type")]
pub enum MessageEvent {
Message {
@@ -149,10 +149,15 @@ pub enum MessageEvent {
UpdateConversation {
conversation: Conversation,
},
/// Sent at the start of an SSE stream to inform the client about
/// in-flight requests it can reattach to.
ActiveRequests {
request_ids: Vec<String>,
},
Ping,
}
async fn get_token_state(session_manager: &SessionManager, session_id: &str) -> TokenState {
pub async fn get_token_state(session_manager: &SessionManager, session_id: &str) -> TokenState {
session_manager
.get_session(session_id, false)
.await
@@ -296,6 +296,13 @@ async fn delete_session(
}
})?;
// Cancel any in-flight replies before dropping the bus, so spawned
// agent tasks stop consuming tokens for a deleted session.
if let Some(bus) = state.get_event_bus(&session_id).await {
bus.cancel_all_requests().await;
}
state.remove_event_bus(&session_id).await;
Ok(StatusCode::OK)
}
@@ -0,0 +1,615 @@
use crate::routes::errors::ErrorResponse;
use crate::routes::reply::{get_token_state, track_tool_telemetry, MessageEvent};
use crate::state::AppState;
use axum::{
extract::{DefaultBodyLimit, Path, State},
http::{self, HeaderMap},
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use bytes::Bytes;
use futures::{stream::StreamExt, Stream};
use goose::agents::{AgentEvent, SessionConfig};
use goose::conversation::message::Message;
use goose::conversation::Conversation;
use serde::{Deserialize, Serialize};
use std::{
convert::Infallible,
pin::Pin,
sync::Arc,
task::{Context, Poll},
time::Duration,
};
use tokio::sync::mpsc;
use tokio::time::timeout;
use tokio_stream::wrappers::ReceiverStream;
// ── Request / Response types ────────────────────────────────────────────
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
pub struct SessionReplyRequest {
/// Client-generated UUIDv7 identifying this request.
pub request_id: String,
pub user_message: Message,
#[serde(default)]
pub override_conversation: Option<Vec<Message>>,
pub recipe_name: Option<String>,
pub recipe_version: Option<String>,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct SessionReplyResponse {
pub request_id: String,
}
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
pub struct CancelRequest {
pub request_id: String,
}
// ── SSE Event Stream Response ───────────────────────────────────────────
/// An SSE response that includes `id:` lines for Last-Event-ID reconnection.
pub struct SseEventStream {
rx: ReceiverStream<String>,
}
impl SseEventStream {
fn new(rx: ReceiverStream<String>) -> Self {
Self { rx }
}
}
impl Stream for SseEventStream {
type Item = Result<Bytes, Infallible>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
Pin::new(&mut self.rx)
.poll_next(cx)
.map(|opt| opt.map(|s| Ok(Bytes::from(s))))
}
}
impl IntoResponse for SseEventStream {
fn into_response(self) -> axum::response::Response {
let body = axum::body::Body::from_stream(self);
http::Response::builder()
.header("Content-Type", "text/event-stream")
.header("Cache-Control", "no-cache")
.header("Connection", "keep-alive")
.body(body)
.unwrap()
}
}
// ── Helpers ─────────────────────────────────────────────────────────────
fn format_sse_event(seq: u64, json: &str) -> String {
format!("id: {}\ndata: {}\n\n", seq, json)
}
fn serialize_session_event(seq: u64, request_id: Option<&str>, event: &MessageEvent) -> String {
// Build JSON payload: { request_id?: string, ...event_fields }
// We flatten request_id into the event JSON.
let mut event_json = serde_json::to_value(event).unwrap_or_else(
|e| serde_json::json!({"type": "Error", "error": format!("Serialization error: {}", e)}),
);
if let Some(rid) = request_id {
if let serde_json::Value::Object(ref mut map) = event_json {
// Always insert chat_request_id for routing (the chat UUID that
// the frontend registered its listener under).
map.insert(
"chat_request_id".to_string(),
serde_json::Value::String(rid.to_string()),
);
// Also set request_id if the event doesn't already carry one
// (e.g. Notification events have their own request_id for tool-call matching)
map.entry("request_id")
.or_insert_with(|| serde_json::Value::String(rid.to_string()));
}
}
let json_str = serde_json::to_string(&event_json).unwrap_or_default();
format_sse_event(seq, &json_str)
}
// ── GET /sessions/{id}/events ───────────────────────────────────────────
#[utoipa::path(
get,
path = "/sessions/{id}/events",
params(
("id" = String, Path, description = "Session ID"),
),
responses(
(status = 200, description = "SSE event stream",
body = MessageEvent,
content_type = "text/event-stream"),
(status = 404, description = "Session not found"),
)
)]
pub async fn session_events(
State(state): State<Arc<AppState>>,
Path(session_id): Path<String>,
headers: HeaderMap,
) -> Result<SseEventStream, axum::http::StatusCode> {
// Validate the session exists before creating an event bus.
state
.session_manager()
.get_session(&session_id, false)
.await
.map_err(|_| axum::http::StatusCode::NOT_FOUND)?;
let last_event_id: Option<u64> = headers
.get("Last-Event-ID")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse().ok());
let bus = state.get_or_create_event_bus(&session_id).await;
let (replay, replay_max_seq, mut live_rx) = match bus.subscribe(last_event_id).await {
Ok(result) => result,
Err(_) => {
// Client's Last-Event-ID has been evicted from the replay buffer.
// Send a single error event so the client knows to reload.
let (tx, rx) = mpsc::channel::<String>(1);
let stream = ReceiverStream::new(rx);
let seq = 0;
let error_event = MessageEvent::Error {
error: "Client too far behind — reload conversation".to_string(),
};
let frame = serialize_session_event(seq, None, &error_event);
tokio::spawn(async move {
let _ = tx.send(frame).await;
});
return Ok(SseEventStream::new(stream));
}
};
let (tx, rx) = mpsc::channel::<String>(256);
let stream = ReceiverStream::new(rx);
let task_bus = bus.clone();
tokio::spawn(async move {
let bus = task_bus;
// Notify the client about any in-flight requests BEFORE replay
// so it can register event handlers before replayed events arrive.
// Emitted without an SSE `id:` field so it doesn't regress the
// client's Last-Event-ID cursor.
let active_ids = bus.active_request_ids().await;
if !active_ids.is_empty() {
let event = MessageEvent::ActiveRequests {
request_ids: active_ids,
};
let json_str = serde_json::to_string(&serde_json::to_value(&event).unwrap_or_default())
.unwrap_or_default();
let frame = format!("data: {}\n\n", json_str);
if tx.send(frame).await.is_err() {
return;
}
}
// Send replayed events
for event in &replay {
let frame =
serialize_session_event(event.seq, event.request_id.as_deref(), &event.event);
if tx.send(frame).await.is_err() {
return;
}
}
// Send live events + heartbeat pings
let mut heartbeat_interval = tokio::time::interval(Duration::from_millis(500));
// Heartbeat uses a local counter — not stored in the replay buffer
let mut heartbeat_seq = 0u64;
loop {
tokio::select! {
_ = heartbeat_interval.tick() => {
// Send heartbeat directly without publishing to the bus,
// so pings don't evict real events from the replay buffer.
// Use a comment-style SSE id so it won't interfere with Last-Event-ID.
let frame = format!(": ping {}\n\n", heartbeat_seq);
heartbeat_seq += 1;
if tx.send(frame).await.is_err() {
return;
}
}
result = live_rx.recv() => {
match result {
Ok(event) => {
// Skip events already covered by replay to avoid duplicates
// at the replay/live handoff boundary.
if event.seq <= replay_max_seq {
continue;
}
let frame = serialize_session_event(
event.seq,
event.request_id.as_deref(),
&event.event,
);
if tx.send(frame).await.is_err() {
return;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!("SSE subscriber lagged by {} events, closing stream so client reconnects with Last-Event-ID", n);
// Close the stream so the client reconnects and
// replays missed events from the buffer.
return;
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
return;
}
}
}
}
}
});
Ok(SseEventStream::new(stream))
}
// ── POST /sessions/{id}/reply ───────────────────────────────────────────
#[utoipa::path(
post,
path = "/sessions/{id}/reply",
params(
("id" = String, Path, description = "Session ID"),
),
request_body = SessionReplyRequest,
responses(
(status = 200, description = "Request accepted",
body = SessionReplyResponse),
(status = 400, description = "Invalid request"),
(status = 404, description = "Session not found"),
(status = 424, description = "Agent not initialized"),
(status = 500, description = "Internal server error"),
)
)]
pub async fn session_reply(
State(state): State<Arc<AppState>>,
Path(session_id): Path<String>,
Json(request): Json<SessionReplyRequest>,
) -> Result<Json<SessionReplyResponse>, ErrorResponse> {
let request_id = request.request_id.clone();
// Validate request_id is a valid UUID
if uuid::Uuid::parse_str(&request_id).is_err() {
return Err(ErrorResponse::bad_request(
"request_id must be a valid UUID",
));
}
// Validate session exists before allocating a bus/registering work
state
.session_manager()
.get_session(&session_id, false)
.await
.map_err(|_| ErrorResponse::not_found(format!("Session {} not found", session_id)))?;
let session_start = std::time::Instant::now();
tracing::info!(
monotonic_counter.goose.session_starts = 1,
session_type = "app",
interface = "ui",
"Session started"
);
if let Some(recipe_name) = request.recipe_name.clone() {
if state.mark_recipe_run_if_absent(&session_id).await {
let recipe_version = request
.recipe_version
.clone()
.unwrap_or_else(|| "unknown".to_string());
tracing::info!(
monotonic_counter.goose.recipe_runs = 1,
recipe_name = %recipe_name,
recipe_version = %recipe_version,
session_type = "app",
interface = "ui",
"Recipe execution started"
);
}
}
let bus = state.get_or_create_event_bus(&session_id).await;
let cancel_token = bus.register_request(request_id.clone()).await;
let user_message = request.user_message;
let override_conversation = request.override_conversation;
let task_state = state.clone();
let task_session_id = session_id.clone();
let task_request_id = request_id.clone();
let task_cancel = cancel_token.clone();
let task_bus = bus.clone();
drop(tokio::spawn(async move {
let publish = |rid: Option<String>, event: MessageEvent| {
let bus = task_bus.clone();
async move {
bus.publish(rid, event).await;
}
};
let agent = match task_state.get_agent(task_session_id.clone()).await {
Ok(agent) => agent,
Err(e) => {
tracing::error!("Failed to get session agent: {}", e);
publish(
Some(task_request_id.clone()),
MessageEvent::Error {
error: format!("Failed to get session agent: {}", e),
},
)
.await;
task_bus.cleanup_request(&task_request_id).await;
return;
}
};
let session = match task_state
.session_manager()
.get_session(&task_session_id, true)
.await
{
Ok(metadata) => metadata,
Err(e) => {
tracing::error!("Failed to read session for {}: {}", task_session_id, e);
publish(
Some(task_request_id.clone()),
MessageEvent::Error {
error: format!("Failed to read session: {}", e),
},
)
.await;
task_bus.cleanup_request(&task_request_id).await;
return;
}
};
let session_config = SessionConfig {
id: task_session_id.clone(),
schedule_id: session.schedule_id.clone(),
max_turns: None,
retry_config: None,
};
let mut all_messages = match override_conversation {
Some(history) => {
let conv = Conversation::new_unvalidated(history);
if let Err(e) = task_state
.session_manager()
.replace_conversation(&task_session_id, &conv)
.await
{
tracing::warn!(
"Failed to replace session conversation for {}: {}",
task_session_id,
e
);
}
conv
}
None => session.conversation.unwrap_or_default(),
};
all_messages.push(user_message.clone());
let mut stream = match agent
.reply(
user_message.clone(),
session_config,
Some(task_cancel.clone()),
)
.await
{
Ok(stream) => stream,
Err(e) => {
tracing::error!("Failed to start reply stream: {:?}", e);
publish(
Some(task_request_id.clone()),
MessageEvent::Error {
error: e.to_string(),
},
)
.await;
task_bus.cleanup_request(&task_request_id).await;
return;
}
};
loop {
tokio::select! {
_ = task_cancel.cancelled() => {
tracing::info!("Agent task cancelled for request {}", task_request_id);
break;
}
response = timeout(Duration::from_millis(500), stream.next()) => {
match response {
Ok(Some(Ok(AgentEvent::Message(message)))) => {
for content in &message.content {
track_tool_telemetry(content, all_messages.messages());
}
all_messages.push(message.clone());
let token_state = get_token_state(
task_state.session_manager(),
&task_session_id,
)
.await;
publish(
Some(task_request_id.clone()),
MessageEvent::Message {
message,
token_state,
},
)
.await;
}
Ok(Some(Ok(AgentEvent::HistoryReplaced(new_messages)))) => {
all_messages = new_messages.clone();
publish(
Some(task_request_id.clone()),
MessageEvent::UpdateConversation {
conversation: new_messages,
},
)
.await;
}
Ok(Some(Ok(AgentEvent::ModelChange { model, mode }))) => {
publish(
Some(task_request_id.clone()),
MessageEvent::ModelChange { model, mode },
)
.await;
}
Ok(Some(Ok(AgentEvent::McpNotification((notification_request_id, n))))) => {
publish(
Some(task_request_id.clone()),
MessageEvent::Notification {
request_id: notification_request_id,
message: n,
},
)
.await;
}
Ok(Some(Err(e))) => {
tracing::error!("Error processing message: {}", e);
publish(
Some(task_request_id.clone()),
MessageEvent::Error {
error: e.to_string(),
},
)
.await;
break;
}
Ok(None) => {
break;
}
Err(_) => {
// Timeout — check if the bus still has subscribers
continue;
}
}
}
}
}
// Telemetry
let session_duration = session_start.elapsed();
if let Ok(session) = task_state
.session_manager()
.get_session(&task_session_id, true)
.await
{
let total_tokens = session.total_tokens.unwrap_or(0);
tracing::info!(
monotonic_counter.goose.session_completions = 1,
session_type = "app",
interface = "ui",
exit_type = "normal",
duration_ms = session_duration.as_millis() as u64,
total_tokens = total_tokens,
message_count = session.message_count,
"Session completed"
);
tracing::info!(
monotonic_counter.goose.session_duration_ms = session_duration.as_millis() as u64,
session_type = "app",
interface = "ui",
"Session duration"
);
if total_tokens > 0 {
tracing::info!(
monotonic_counter.goose.session_tokens = total_tokens,
session_type = "app",
interface = "ui",
"Session tokens"
);
}
} else {
tracing::info!(
monotonic_counter.goose.session_completions = 1,
session_type = "app",
interface = "ui",
exit_type = "normal",
duration_ms = session_duration.as_millis() as u64,
total_tokens = 0u64,
message_count = all_messages.len(),
"Session completed"
);
tracing::info!(
monotonic_counter.goose.session_duration_ms = session_duration.as_millis() as u64,
session_type = "app",
interface = "ui",
"Session duration"
);
}
let final_token_state =
get_token_state(task_state.session_manager(), &task_session_id).await;
publish(
Some(task_request_id.clone()),
MessageEvent::Finish {
reason: "stop".to_string(),
token_state: final_token_state,
},
)
.await;
task_bus.cleanup_request(&task_request_id).await;
}));
Ok(Json(SessionReplyResponse { request_id }))
}
// ── POST /sessions/{id}/cancel ──────────────────────────────────────────
#[utoipa::path(
post,
path = "/sessions/{id}/cancel",
params(
("id" = String, Path, description = "Session ID"),
),
request_body = CancelRequest,
responses(
(status = 200, description = "Cancellation accepted"),
)
)]
pub async fn session_cancel(
State(state): State<Arc<AppState>>,
Path(session_id): Path<String>,
Json(request): Json<CancelRequest>,
) -> axum::http::StatusCode {
let bus = match state.get_event_bus(&session_id).await {
Some(bus) => bus,
None => return axum::http::StatusCode::NOT_FOUND,
};
bus.cancel_request(&request.request_id).await;
axum::http::StatusCode::OK
}
// ── Route registration ──────────────────────────────────────────────────
pub fn routes(state: Arc<AppState>) -> Router {
Router::new()
.route("/sessions/{id}/events", get(session_events))
.route(
"/sessions/{id}/reply",
post(session_reply).layer(DefaultBodyLimit::max(50 * 1024 * 1024)),
)
.route("/sessions/{id}/cancel", post(session_cancel))
.with_state(state)
}
@@ -0,0 +1,259 @@
use crate::routes::reply::MessageEvent;
use std::collections::{HashMap, VecDeque};
use std::sync::atomic::{AtomicU64, Ordering};
use tokio::sync::{broadcast, Mutex};
use tokio_util::sync::CancellationToken;
const BROADCAST_CAPACITY: usize = 256;
const REPLAY_BUFFER_CAPACITY: usize = 512;
/// Error returned by [`SessionEventBus::subscribe`].
#[derive(Debug)]
pub enum SubscribeError {
/// The client's `Last-Event-ID` has been evicted from the replay buffer,
/// so events have been irrecoverably lost.
ClientTooFarBehind,
}
#[derive(Clone, Debug)]
pub struct SessionEvent {
/// Monotonic sequence number, written as SSE `id:` frame (not in JSON payload).
pub seq: u64,
/// None for Ping events, Some for events associated with a specific request.
pub request_id: Option<String>,
/// The event payload.
pub event: MessageEvent,
}
pub struct SessionEventBus {
tx: broadcast::Sender<SessionEvent>,
buffer: Mutex<VecDeque<SessionEvent>>,
next_seq: AtomicU64,
active_requests: Mutex<HashMap<String, CancellationToken>>,
}
impl SessionEventBus {
pub fn new() -> Self {
let (tx, _) = broadcast::channel(BROADCAST_CAPACITY);
Self {
tx,
buffer: Mutex::new(VecDeque::with_capacity(REPLAY_BUFFER_CAPACITY)),
next_seq: AtomicU64::new(1),
active_requests: Mutex::new(HashMap::new()),
}
}
/// Publish an event to the bus. Assigns a monotonic sequence number.
///
/// The sequence ID is assigned under the buffer lock so that concurrent
/// callers cannot reorder events (i.e. seq=2 published before seq=1).
pub async fn publish(&self, request_id: Option<String>, event: MessageEvent) -> u64 {
let session_event = {
let mut buf = self.buffer.lock().await;
let seq = self.next_seq.fetch_add(1, Ordering::Relaxed);
let session_event = SessionEvent {
seq,
request_id,
event,
};
buf.push_back(session_event.clone());
while buf.len() > REPLAY_BUFFER_CAPACITY {
buf.pop_front();
}
session_event
};
// Send on broadcast channel (ignore error if no subscribers)
let _ = self.tx.send(session_event.clone());
session_event.seq
}
/// Subscribe to live events. If `last_event_id` is provided, replay buffered
/// events with seq > last_event_id. Returns (replay_events, replay_max_seq, live_receiver).
///
/// Returns `Err(SubscribeError::ClientTooFarBehind)` when `last_event_id`
/// refers to an event that has already been evicted from the replay buffer,
/// meaning the client has irrecoverably missed events.
///
/// The live receiver is created *before* snapshotting the buffer so that
/// no event can fall into the gap between the two steps. The caller must
/// skip live events with `seq <= replay_max_seq` to deduplicate.
pub async fn subscribe(
&self,
last_event_id: Option<u64>,
) -> Result<(Vec<SessionEvent>, u64, broadcast::Receiver<SessionEvent>), SubscribeError> {
// Subscribe first so that any event published while we hold the
// buffer lock is guaranteed to appear in `rx` (possibly duplicating
// a replay entry). The caller deduplicates via replay_max_seq.
let rx = self.tx.subscribe();
let (replay, replay_max_seq) = {
let buf = self.buffer.lock().await;
let buf_max = buf.back().map(|e| e.seq).unwrap_or(0);
let buf_min = buf.front().map(|e| e.seq).unwrap_or(0);
let last_id = last_event_id.unwrap_or(0);
// If the client sent a Last-Event-ID that has been evicted from
// the buffer, they have irrecoverably missed events.
if last_id > 0 && buf_min > 0 && last_id < buf_min {
return Err(SubscribeError::ClientTooFarBehind);
}
// Clamp to the actual buffer max so a stale Last-Event-ID
// (e.g. from before a server restart) doesn't suppress live events.
let events: Vec<_> = buf.iter().filter(|e| e.seq > last_id).cloned().collect();
let max_seq = events.last().map(|e| e.seq).unwrap_or(last_id.min(buf_max));
(events, max_seq)
};
Ok((replay, replay_max_seq, rx))
}
/// Return the IDs of all currently active (in-flight) requests.
pub async fn active_request_ids(&self) -> Vec<String> {
let requests = self.active_requests.lock().await;
requests.keys().cloned().collect()
}
/// Register a new request and return its cancellation token.
pub async fn register_request(&self, request_id: String) -> CancellationToken {
let token = CancellationToken::new();
let mut requests = self.active_requests.lock().await;
requests.insert(request_id, token.clone());
token
}
/// Cancel a specific request by request_id.
pub async fn cancel_request(&self, request_id: &str) -> bool {
let requests = self.active_requests.lock().await;
if let Some(token) = requests.get(request_id) {
token.cancel();
true
} else {
false
}
}
/// Cancel all active requests (e.g. when deleting a session).
pub async fn cancel_all_requests(&self) {
let requests = self.active_requests.lock().await;
for token in requests.values() {
token.cancel();
}
}
/// Remove the cancellation token for a completed request.
pub async fn cleanup_request(&self, request_id: &str) {
let mut requests = self.active_requests.lock().await;
requests.remove(request_id);
}
}
impl Default for SessionEventBus {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use goose::conversation::message::TokenState;
#[tokio::test]
async fn test_publish_and_subscribe() {
let bus = SessionEventBus::new();
// Publish some events
bus.publish(Some("req-1".to_string()), MessageEvent::Ping)
.await;
bus.publish(
Some("req-1".to_string()),
MessageEvent::Finish {
reason: "stop".to_string(),
token_state: TokenState::default(),
},
)
.await;
// Subscribe with replay
let (replay, replay_max_seq, _rx) = bus.subscribe(Some(0)).await.unwrap();
assert_eq!(replay.len(), 2);
assert_eq!(replay[0].seq, 1);
assert_eq!(replay[1].seq, 2);
assert_eq!(replay_max_seq, 2);
}
#[tokio::test]
async fn test_subscribe_with_last_event_id() {
let bus = SessionEventBus::new();
bus.publish(None, MessageEvent::Ping).await;
bus.publish(None, MessageEvent::Ping).await;
bus.publish(None, MessageEvent::Ping).await;
// Only get events after seq 2
let (replay, replay_max_seq, _rx) = bus.subscribe(Some(2)).await.unwrap();
assert_eq!(replay.len(), 1);
assert_eq!(replay[0].seq, 3);
assert_eq!(replay_max_seq, 3);
}
#[tokio::test]
async fn test_subscribe_without_last_event_id_replays_all() {
let bus = SessionEventBus::new();
bus.publish(None, MessageEvent::Ping).await;
bus.publish(None, MessageEvent::Ping).await;
// First connect (no Last-Event-ID) should replay all buffered events
let (replay, replay_max_seq, _rx) = bus.subscribe(None).await.unwrap();
assert_eq!(replay.len(), 2);
assert_eq!(replay_max_seq, 2);
}
#[tokio::test]
async fn test_subscribe_with_stale_last_event_id() {
let bus = SessionEventBus::new();
// Buffer has seq 1..3, but client sends Last-Event-ID: 9999
bus.publish(None, MessageEvent::Ping).await;
bus.publish(None, MessageEvent::Ping).await;
bus.publish(None, MessageEvent::Ping).await;
let (replay, replay_max_seq, _rx) = bus.subscribe(Some(9999)).await.unwrap();
// No replay events (all are below 9999)
assert_eq!(replay.len(), 0);
// replay_max_seq should be clamped to buf_max (3), not 9999
assert_eq!(replay_max_seq, 3);
}
#[tokio::test]
async fn test_cancel_request() {
let bus = SessionEventBus::new();
let token = bus.register_request("req-1".to_string()).await;
assert!(!token.is_cancelled());
let cancelled = bus.cancel_request("req-1").await;
assert!(cancelled);
assert!(token.is_cancelled());
// Non-existent request
let cancelled = bus.cancel_request("req-999").await;
assert!(!cancelled);
}
#[tokio::test]
async fn test_cleanup_request() {
let bus = SessionEventBus::new();
bus.register_request("req-1".to_string()).await;
bus.cleanup_request("req-1").await;
// Should return false since it was cleaned up
let cancelled = bus.cancel_request("req-1").await;
assert!(!cancelled);
}
}
+23
View File
@@ -9,6 +9,7 @@ use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
use crate::session_event_bus::SessionEventBus;
use crate::tunnel::TunnelManager;
use goose::agents::ExtensionLoadResult;
use goose::gateway::manager::GatewayManager;
@@ -26,6 +27,7 @@ pub struct AppState {
pub gateway_manager: Arc<GatewayManager>,
pub extension_loading_tasks: ExtensionLoadingTasks,
pub inference_runtime: Arc<InferenceRuntime>,
session_buses: Arc<Mutex<HashMap<String, Arc<SessionEventBus>>>>,
}
impl AppState {
@@ -44,6 +46,7 @@ impl AppState {
gateway_manager,
extension_loading_tasks: Arc::new(Mutex::new(HashMap::new())),
inference_runtime: InferenceRuntime::get_or_init(),
session_buses: Arc::new(Mutex::new(HashMap::new())),
}))
}
@@ -107,6 +110,26 @@ impl AppState {
}
}
pub async fn get_or_create_event_bus(&self, session_id: &str) -> Arc<SessionEventBus> {
let mut buses = self.session_buses.lock().await;
buses
.entry(session_id.to_string())
.or_insert_with(|| Arc::new(SessionEventBus::new()))
.clone()
}
/// Get an existing event bus for a session without creating one.
pub async fn get_event_bus(&self, session_id: &str) -> Option<Arc<SessionEventBus>> {
let buses = self.session_buses.lock().await;
buses.get(session_id).cloned()
}
/// Remove the event bus for a session, freeing its replay buffer.
pub async fn remove_event_bus(&self, session_id: &str) {
let mut buses = self.session_buses.lock().await;
buses.remove(session_id);
}
pub async fn get_agent(&self, session_id: String) -> anyhow::Result<Arc<goose::agents::Agent>> {
self.agent_manager.get_or_create_agent(session_id).await
}
+196
View File
@@ -3281,6 +3281,127 @@
]
}
},
"/sessions/{id}/cancel": {
"post": {
"tags": [
"super::routes::session_events"
],
"operationId": "session_cancel",
"parameters": [
{
"name": "id",
"in": "path",
"description": "Session ID",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CancelRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Cancellation accepted"
}
}
}
},
"/sessions/{id}/events": {
"get": {
"tags": [
"super::routes::session_events"
],
"operationId": "session_events",
"parameters": [
{
"name": "id",
"in": "path",
"description": "Session ID",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "SSE event stream",
"content": {
"text/event-stream": {
"schema": {
"$ref": "#/components/schemas/MessageEvent"
}
}
}
},
"404": {
"description": "Session not found"
}
}
}
},
"/sessions/{id}/reply": {
"post": {
"tags": [
"super::routes::session_events"
],
"operationId": "session_reply",
"parameters": [
{
"name": "id",
"in": "path",
"description": "Session ID",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionReplyRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Request accepted",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionReplyResponse"
}
}
}
},
"400": {
"description": "Invalid request"
},
"404": {
"description": "Session not found"
},
"424": {
"description": "Agent not initialized"
},
"500": {
"description": "Internal server error"
}
}
}
},
"/sessions/{session_id}": {
"get": {
"tags": [
@@ -3974,6 +4095,17 @@
}
}
},
"CancelRequest": {
"type": "object",
"required": [
"request_id"
],
"properties": {
"request_id": {
"type": "string"
}
}
},
"ChatRequest": {
"type": "object",
"required": [
@@ -6043,6 +6175,28 @@
}
}
},
{
"type": "object",
"description": "Sent at the start of an SSE stream to inform the client about\nin-flight requests it can reattach to.",
"required": [
"request_ids",
"type"
],
"properties": {
"request_ids": {
"type": "array",
"items": {
"type": "string"
}
},
"type": {
"type": "string",
"enum": [
"ActiveRequests"
]
}
}
},
{
"type": "object",
"required": [
@@ -7738,6 +7892,48 @@
}
}
},
"SessionReplyRequest": {
"type": "object",
"required": [
"request_id",
"user_message"
],
"properties": {
"override_conversation": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Message"
},
"nullable": true
},
"recipe_name": {
"type": "string",
"nullable": true
},
"recipe_version": {
"type": "string",
"nullable": true
},
"request_id": {
"type": "string",
"description": "Client-generated UUIDv7 identifying this request."
},
"user_message": {
"$ref": "#/components/schemas/Message"
}
}
},
"SessionReplyResponse": {
"type": "object",
"required": [
"request_id"
],
"properties": {
"request_id": {
"type": "string"
}
}
},
"SessionType": {
"type": "string",
"enum": [
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+109
View File
@@ -59,6 +59,10 @@ export type CallToolResponse = {
structuredContent?: unknown;
};
export type CancelRequest = {
request_id: string;
};
export type ChatRequest = {
/**
* Override the server's conversation history. Only use this when you need absolute control
@@ -696,6 +700,9 @@ export type MessageEvent = {
} | {
conversation: Conversation;
type: 'UpdateConversation';
} | {
request_ids: Array<string>;
type: 'ActiveRequests';
} | {
type: 'Ping';
};
@@ -1255,6 +1262,21 @@ export type SessionListResponse = {
sessions: Array<Session>;
};
export type SessionReplyRequest = {
override_conversation?: Array<Message> | null;
recipe_name?: string | null;
recipe_version?: string | null;
/**
* Client-generated UUIDv7 identifying this request.
*/
request_id: string;
user_message: Message;
};
export type SessionReplyResponse = {
request_id: string;
};
export type SessionType = 'user' | 'scheduled' | 'sub_agent' | 'hidden' | 'terminal' | 'gateway';
export type SessionsQuery = {
@@ -4129,6 +4151,93 @@ export type SearchSessionsResponses = {
export type SearchSessionsResponse = SearchSessionsResponses[keyof SearchSessionsResponses];
export type SessionCancelData = {
body: CancelRequest;
path: {
/**
* Session ID
*/
id: string;
};
query?: never;
url: '/sessions/{id}/cancel';
};
export type SessionCancelResponses = {
/**
* Cancellation accepted
*/
200: unknown;
};
export type SessionEventsData = {
body?: never;
path: {
/**
* Session ID
*/
id: string;
};
query?: never;
url: '/sessions/{id}/events';
};
export type SessionEventsErrors = {
/**
* Session not found
*/
404: unknown;
};
export type SessionEventsResponses = {
/**
* SSE event stream
*/
200: MessageEvent;
};
export type SessionEventsResponse = SessionEventsResponses[keyof SessionEventsResponses];
export type SessionReplyData = {
body: SessionReplyRequest;
path: {
/**
* Session ID
*/
id: string;
};
query?: never;
url: '/sessions/{id}/reply';
};
export type SessionReplyErrors = {
/**
* Invalid request
*/
400: unknown;
/**
* Session not found
*/
404: unknown;
/**
* Agent not initialized
*/
424: unknown;
/**
* Internal server error
*/
500: unknown;
};
export type SessionReplyResponses = {
/**
* Request accepted
*/
200: SessionReplyResponse;
};
export type SessionReplyResponse2 = SessionReplyResponses[keyof SessionReplyResponses];
export type DeleteSessionData = {
body?: never;
path: {
+384 -198
View File
@@ -1,15 +1,15 @@
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
import { v7 as uuidv7 } from 'uuid';
import { AppEvents } from '../constants/events';
import { ChatState } from '../types/chatState';
import { toastError } from '../toasts';
import {
getSession,
Message,
MessageEvent,
reply,
resumeAgent,
Session,
sessionCancel,
sessionReply,
TokenState,
updateFromSession,
updateSessionUserRecipeValues,
@@ -27,6 +27,7 @@ import {
import { errorMessage } from '../utils/conversionUtils';
import { showExtensionLoadResults } from '../utils/extensionErrorUtils';
import { maybeHandlePlatformEvent } from '../utils/platform_events';
import { useSessionEvents, type SessionEvent } from './useSessionEvents';
const resultsCache = new Map<string, { messages: Message[]; session: Session }>();
@@ -217,14 +218,17 @@ function prefersReducedMotion(): boolean {
const REDUCED_MOTION_BATCH_INTERVAL = 1000;
async function streamFromResponse(
stream: AsyncIterable<MessageEvent>,
/**
* Creates an event processor that handles individual SSE events for a request.
* Returns an unsubscribe function and a handler to process events.
*/
function createEventProcessor(
initialMessages: Message[],
dispatch: React.Dispatch<StreamAction>,
onFinish: (error?: string) => void,
sessionId: string,
signal?: AbortController['signal']
): Promise<void> {
onReloadNeeded?: () => void,
) {
let currentMessages = initialMessages;
const reduceMotion = prefersReducedMotion();
let latestTokenState: TokenState | null = null;
@@ -266,88 +270,80 @@ async function streamFromResponse(
}
};
try {
for await (const event of stream) {
switch (event.type) {
case 'Message': {
const msg = event.message;
currentMessages = pushMessage(currentMessages, msg);
// Returns true if the event is terminal (Finish or Error)
const processEvent = (event: SessionEvent): boolean => {
switch (event.type) {
case 'Message': {
const msg = (event as Record<string, unknown>).message as Message;
const tokenState = (event as Record<string, unknown>).token_state as TokenState;
currentMessages = pushMessage(currentMessages, msg);
const hasToolConfirmation = msg.content.some(
(content) =>
content.type === 'actionRequired' && content.data.actionType === 'toolConfirmation'
);
const hasToolConfirmation = msg.content.some(
(content) =>
content.type === 'actionRequired' && content.data.actionType === 'toolConfirmation'
);
const hasElicitation = msg.content.some(
(content) =>
content.type === 'actionRequired' && content.data.actionType === 'elicitation'
);
const hasElicitation = msg.content.some(
(content) =>
content.type === 'actionRequired' && content.data.actionType === 'elicitation'
);
if (hasToolConfirmation || hasElicitation) {
maybeUpdateUI(event.token_state, ChatState.WaitingForUserInput, true);
} else if (getCompactingMessage(msg)) {
maybeUpdateUI(event.token_state, ChatState.Compacting);
} else if (getThinkingMessage(msg)) {
maybeUpdateUI(event.token_state, ChatState.Thinking);
} else {
maybeUpdateUI(event.token_state, ChatState.Streaming);
}
break;
if (hasToolConfirmation || hasElicitation) {
maybeUpdateUI(tokenState, ChatState.WaitingForUserInput, true);
} else if (getCompactingMessage(msg)) {
maybeUpdateUI(tokenState, ChatState.Compacting);
} else if (getThinkingMessage(msg)) {
maybeUpdateUI(tokenState, ChatState.Thinking);
} else {
maybeUpdateUI(tokenState, ChatState.Streaming);
}
case 'Error': {
flushBatchedUpdates();
onFinish('Stream error: ' + event.error);
return;
}
case 'Finish': {
flushBatchedUpdates();
onFinish();
return;
}
case 'ModelChange': {
break;
}
case 'UpdateConversation': {
currentMessages = event.conversation;
if (!reduceMotion) {
dispatch({ type: 'SET_MESSAGES', payload: event.conversation });
} else {
hasPendingUpdate = true;
}
break;
}
case 'Notification': {
dispatch({ type: 'ADD_NOTIFICATION', payload: event as NotificationEvent });
maybeHandlePlatformEvent(event.message, sessionId);
break;
}
case 'Ping':
break;
return false;
}
case 'Error': {
flushBatchedUpdates();
const errorMsg = String((event as Record<string, unknown>).error ?? '');
if (errorMsg.includes('too far behind') && onReloadNeeded) {
// Server indicated we missed events — end streaming without setting
// an error (which would show a blocking error screen), then reload
// the full conversation so the UI reflects the actual state.
onFinish();
onReloadNeeded();
} else {
onFinish('Stream error: ' + errorMsg);
}
return true;
}
case 'Finish': {
flushBatchedUpdates();
onFinish();
return true;
}
case 'ModelChange': {
return false;
}
case 'UpdateConversation': {
const conversation = (event as Record<string, unknown>).conversation as Message[];
currentMessages = conversation;
if (!reduceMotion) {
dispatch({ type: 'SET_MESSAGES', payload: conversation });
} else {
hasPendingUpdate = true;
}
return false;
}
case 'Notification': {
dispatch({ type: 'ADD_NOTIFICATION', payload: event as unknown as NotificationEvent });
maybeHandlePlatformEvent((event as Record<string, unknown>).message, sessionId);
return false;
}
case 'Ping':
return false;
default:
return false;
}
};
// If we reach here, the stream ended without a Finish or Error event.
// This happens when the connection drops and retries are exhausted — the
// generator exits its loop without yielding a terminal event. We call
// onFinish() without an error to keep the conversation visible (passing
// an error would trigger a full-page error screen via sessionLoadError),
// then show a toast so the user knows the response may be incomplete.
// If the signal was aborted, the user intentionally stopped streaming,
// so we skip the toast.
flushBatchedUpdates();
onFinish();
if (!signal?.aborted) {
toastError({
title: 'Connection lost',
msg: 'The response may be incomplete. You can try sending your message again.',
});
}
} catch (error) {
flushBatchedUpdates();
if (error instanceof Error && error.name !== 'AbortError') {
onFinish('Stream error: ' + errorMessage(error));
}
}
return processEvent;
}
export function useChatStream({
@@ -357,14 +353,26 @@ export function useChatStream({
}: UseChatStreamProps): UseChatStreamReturn {
const [state, dispatch] = useReducer(streamReducer, initialState);
// Refs for values needed in callbacks without causing re-renders
const abortControllerRef = useRef<AbortController | null>(null);
// Long-lived SSE connection for this session
const { addListener, setActiveRequestsHandler } = useSessionEvents(sessionId);
// Track the active request for cancellation (includes the session that started it)
const activeRequestIdRef = useRef<string | null>(null);
const activeRequestSessionIdRef = useRef<string | null>(null);
const activeAbortRef = useRef<AbortController | null>(null);
const activeUnsubscribeRef = useRef<(() => void) | null>(null);
const lastInteractionTimeRef = useRef<number>(Date.now());
// When ActiveRequests fires before resumeAgent populates messages (cold mount),
// defer the reattach until the session is loaded so the event processor has
// the full conversation history. Events are buffered in the meantime.
const pendingReattachRequestIdRef = useRef<string | null>(null);
const pendingReattachBufferRef = useRef<SessionEvent[]>([]);
const namePollingRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Ref to access latest state in callbacks (avoids stale closures)
const stateRef = useRef(state);
stateRef.current = state;
const doReattachRef = useRef<((requestId: string, messages: Message[]) => void) | null>(null);
useEffect(() => {
return () => {
@@ -383,6 +391,10 @@ export function useChatStream({
const onFinish = useCallback(
async (error?: string): Promise<void> => {
// Note: SSE listener/ref cleanup is handled by the terminal-event
// handler in each listener closure (which guards on requestId) so
// that overlapping requests don't clobber each other's state.
if (namePollingRef.current) {
clearTimeout(namePollingRef.current);
namePollingRef.current = null;
@@ -404,13 +416,10 @@ export function useChatStream({
}
// Refresh session name after each reply for the first 3 user messages
// The backend regenerates the name after each of the first 3 user messages
// to refine it as more context becomes available
if (!error && sessionId) {
const currentState = stateRef.current;
const userMessageCount = currentState.messages.filter((m) => m.role === 'user').length;
// Only refresh for the first 3 user messages
if (userMessageCount <= 3) {
try {
const response = await getSession({
@@ -424,7 +433,6 @@ export function useChatStream({
? { ...currentState.session, name: response.data.name }
: undefined,
});
// Notify sidebar of the name change
window.dispatchEvent(
new CustomEvent(AppEvents.SESSION_RENAMED, {
detail: { sessionId, newName: response.data.name },
@@ -432,7 +440,6 @@ export function useChatStream({
);
}
} catch (refreshError) {
// Silently fail - this is a nice-to-have feature
console.warn('Failed to refresh session name:', refreshError);
}
}
@@ -443,6 +450,208 @@ export function useChatStream({
[onStreamFinish, sessionId]
);
// Reload the full conversation from the server, e.g. after the SSE
// stream indicates the client fell too far behind the replay buffer.
const reloadConversation = useCallback(() => {
getSession({
path: { session_id: sessionId },
throwOnError: true,
}).then((response) => {
const session = response.data as Session;
if (session?.conversation) {
dispatch({ type: 'SET_MESSAGES', payload: session.conversation });
}
}).catch((e) => {
console.warn('Failed to reload conversation after buffer overflow:', e);
});
}, [sessionId]);
// Perform the actual reattach: wire up an event processor and listener
// for a request that is already in-flight on the server.
const doReattach = useCallback(
(requestId: string, messages: Message[]) => {
activeRequestIdRef.current = requestId;
activeRequestSessionIdRef.current = sessionId;
pendingReattachRequestIdRef.current = null;
dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Streaming });
dispatch({ type: 'SET_SESSION_LOAD_ERROR', payload: undefined });
const processEvent = createEventProcessor(
messages,
dispatch,
onFinish,
sessionId,
reloadConversation,
);
// Replay any events that were buffered during cold-mount wait
const buffered = pendingReattachBufferRef.current;
pendingReattachBufferRef.current = [];
let finished = false;
for (const event of buffered) {
if (processEvent(event)) {
finished = true;
break;
}
}
if (finished) {
// The reply already completed while we were waiting for session load.
// Clean up — the buffering listener will be replaced below but the
// old one captured into activeUnsubscribeRef should be removed.
if (activeUnsubscribeRef.current) {
activeUnsubscribeRef.current();
activeUnsubscribeRef.current = null;
}
activeRequestIdRef.current = null;
activeRequestSessionIdRef.current = null;
return;
}
// Replace the buffering listener with a real processing listener
if (activeUnsubscribeRef.current) {
activeUnsubscribeRef.current();
}
const unsubscribe = addListener(requestId, (event) => {
const isTerminal = processEvent(event);
if (isTerminal) {
unsubscribe();
if (activeRequestIdRef.current === requestId) {
activeUnsubscribeRef.current = null;
activeRequestIdRef.current = null;
activeRequestSessionIdRef.current = null;
}
}
});
activeUnsubscribeRef.current = unsubscribe;
},
[sessionId, addListener, onFinish, reloadConversation],
);
doReattachRef.current = doReattach;
// Reattach to in-flight replies discovered via the SSE ActiveRequests event.
// This handles the case where the chat view remounts while a reply is still
// running on the server — the new hook instance picks up the existing request
// and starts processing its events.
useEffect(() => {
setActiveRequestsHandler((requestIds: string[]) => {
// Only reattach if we don't already have an active request
if (activeRequestIdRef.current) return;
if (requestIds.length === 0) return;
// Reattach to the first (most recent) active request.
// Multiple concurrent requests per session aren't supported in the UI.
const requestId = requestIds[0];
const currentMessages = stateRef.current.messages;
if (currentMessages.length === 0) {
// Cold mount: resumeAgent hasn't populated messages yet.
// Defer event processing until session load completes so the
// processor starts with the full conversation history.
// Register a buffering listener NOW so replayed events aren't
// lost while we wait.
pendingReattachRequestIdRef.current = requestId;
pendingReattachBufferRef.current = [];
activeRequestIdRef.current = requestId;
activeRequestSessionIdRef.current = sessionId;
dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Streaming });
dispatch({ type: 'SET_SESSION_LOAD_ERROR', payload: undefined });
const unsubscribe = addListener(requestId, (event) => {
pendingReattachBufferRef.current.push(event);
});
activeUnsubscribeRef.current = unsubscribe;
return;
}
doReattach(requestId, currentMessages);
});
return () => {
setActiveRequestsHandler(null);
};
}, [sessionId, addListener, onFinish, reloadConversation, setActiveRequestsHandler, doReattach]);
/**
* Submit a message via the new POST+SSE pattern.
* 1. Generate request_id
* 2. Register SSE listener BEFORE POST (no race condition)
* 3. POST to /sessions/{id}/reply
* 4. Events arrive on the long-lived SSE connection
*/
const submitToSession = useCallback(
async (
targetSessionId: string,
userMessage: Message,
currentMessages: Message[],
overrideConversation?: Message[],
recipeName?: string,
recipeVersion?: string,
) => {
const requestId = uuidv7();
const abortController = new AbortController();
activeRequestIdRef.current = requestId;
activeRequestSessionIdRef.current = targetSessionId;
activeAbortRef.current = abortController;
// Create event processor and register listener BEFORE the POST
const processEvent = createEventProcessor(
currentMessages,
dispatch,
onFinish,
targetSessionId,
reloadConversation,
);
const unsubscribe = addListener(requestId, (event) => {
const isTerminal = processEvent(event);
if (isTerminal) {
unsubscribe();
// Only clear global refs if this request is still the active one.
// A newer request may have already replaced them.
if (activeRequestIdRef.current === requestId) {
activeUnsubscribeRef.current = null;
activeRequestIdRef.current = null;
activeRequestSessionIdRef.current = null;
activeAbortRef.current = null;
}
}
});
activeUnsubscribeRef.current = unsubscribe;
try {
await sessionReply({
path: { id: targetSessionId },
body: {
request_id: requestId,
user_message: userMessage,
override_conversation: overrideConversation,
recipe_name: recipeName,
recipe_version: recipeVersion,
},
signal: abortController.signal,
throwOnError: true,
});
} catch (error) {
// Abort is expected when stopStreaming races with the POST
if (abortController.signal.aborted) return;
// POST failed — clean up listener and report error.
// Only clear global refs if this request is still the active one;
// a newer request may have already replaced them.
unsubscribe();
if (activeRequestIdRef.current === requestId) {
activeUnsubscribeRef.current = null;
activeRequestIdRef.current = null;
activeRequestSessionIdRef.current = null;
activeAbortRef.current = null;
}
onFinish('Submit error: ' + errorMessage(error));
}
},
[addListener, onFinish, reloadConversation]
);
// Load session on mount or sessionId change
useEffect(() => {
if (!sessionId) return;
@@ -494,12 +703,38 @@ export function useChatStream({
showExtensionLoadResults(extensionResults);
window.dispatchEvent(new CustomEvent(AppEvents.SESSION_EXTENSIONS_LOADED));
dispatch({
type: 'SESSION_LOADED',
payload: {
session: loadedSession!,
messages: loadedSession?.conversation || [],
tokenState: {
const pendingRequestId = pendingReattachRequestIdRef.current;
const reattachedToActiveRequest = activeRequestIdRef.current !== null;
if (pendingRequestId) {
// Cold-mount reattach: ActiveRequests arrived before resumeAgent
// returned. Load session state first, then complete the reattach
// with the full conversation so the event processor has context.
dispatch({
type: 'SESSION_LOADED',
payload: {
session: loadedSession!,
messages: loadedSession?.conversation || [],
tokenState: {
inputTokens: loadedSession?.input_tokens ?? 0,
outputTokens: loadedSession?.output_tokens ?? 0,
totalTokens: loadedSession?.total_tokens ?? 0,
accumulatedInputTokens: loadedSession?.accumulated_input_tokens ?? 0,
accumulatedOutputTokens: loadedSession?.accumulated_output_tokens ?? 0,
accumulatedTotalTokens: loadedSession?.accumulated_total_tokens ?? 0,
},
},
});
// Now complete the deferred reattach with the loaded messages
doReattachRef.current?.(pendingRequestId, loadedSession?.conversation || []);
} else if (reattachedToActiveRequest) {
// ActiveRequests already wired up an event processor with existing
// messages — only load session metadata, don't overwrite messages
// with the stale DB snapshot.
dispatch({ type: 'SET_SESSION', payload: loadedSession });
dispatch({
type: 'SET_TOKEN_STATE',
payload: {
inputTokens: loadedSession?.input_tokens ?? 0,
outputTokens: loadedSession?.output_tokens ?? 0,
totalTokens: loadedSession?.total_tokens ?? 0,
@@ -507,8 +742,24 @@ export function useChatStream({
accumulatedOutputTokens: loadedSession?.accumulated_output_tokens ?? 0,
accumulatedTotalTokens: loadedSession?.accumulated_total_tokens ?? 0,
},
},
});
});
} else {
dispatch({
type: 'SESSION_LOADED',
payload: {
session: loadedSession!,
messages: loadedSession?.conversation || [],
tokenState: {
inputTokens: loadedSession?.input_tokens ?? 0,
outputTokens: loadedSession?.output_tokens ?? 0,
totalTokens: loadedSession?.total_tokens ?? 0,
accumulatedInputTokens: loadedSession?.accumulated_input_tokens ?? 0,
accumulatedOutputTokens: loadedSession?.accumulated_output_tokens ?? 0,
accumulatedTotalTokens: loadedSession?.accumulated_total_tokens ?? 0,
},
},
});
}
listApps({
throwOnError: true,
@@ -535,7 +786,6 @@ export function useChatStream({
const { msg: userMessage, images } = input;
const currentState = stateRef.current;
// Guard: Don't submit if session hasn't been loaded yet
if (!currentState.session || currentState.chatState === ChatState.LoadingConversation) {
return;
}
@@ -543,7 +793,6 @@ export function useChatStream({
const hasExistingMessages = currentState.messages.length > 0;
const hasNewMessage = userMessage.trim().length > 0 || images.length > 0;
// Don't submit if there's no message and no conversation to continue
if (!hasNewMessage && !hasExistingMessages) {
return;
}
@@ -554,10 +803,8 @@ export function useChatStream({
if (!hasExistingMessages && hasNewMessage) {
window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED));
// Start polling for session name update during streaming
// The backend generates the name in parallel with the response
const pollForName = async (attempts = 0) => {
if (attempts >= 20) return; // Max 20 attempts (10 seconds)
if (attempts >= 20) return;
try {
const response = await getSession({
@@ -568,7 +815,6 @@ export function useChatStream({
const currentName = currentState.session?.name;
const newName = response.data?.name;
// Check if name has changed from the initial name
if (newName && newName !== currentName) {
dispatch({
type: 'SET_SESSION',
@@ -581,13 +827,12 @@ export function useChatStream({
detail: { sessionId, newName },
})
);
return; // Stop polling once name is updated
return;
}
} catch {
// Silently continue polling
}
// Continue polling if still streaming
const latestState = stateRef.current;
if (
latestState.chatState === ChatState.Streaming ||
@@ -598,7 +843,6 @@ export function useChatStream({
}
};
// Start polling after a short delay to give backend time to start name generation
namePollingRef.current = setTimeout(() => pollForName(0), 1000);
}
@@ -614,38 +858,10 @@ export function useChatStream({
}
dispatch({ type: 'START_STREAMING' });
abortControllerRef.current = new AbortController();
try {
const { stream } = await reply({
body: {
session_id: sessionId,
user_message: newMessage,
},
throwOnError: true,
signal: abortControllerRef.current.signal,
sseMaxRetryAttempts: 0,
});
await streamFromResponse(
stream,
currentMessages,
dispatch,
onFinish,
sessionId,
abortControllerRef.current.signal
);
} catch (error) {
// AbortError is expected when user stops streaming
if (error instanceof Error && error.name === 'AbortError') {
// Silently handle abort
} else {
// Unexpected error during fetch setup (streamFromResponse handles its own errors)
onFinish('Submit error: ' + errorMessage(error));
}
}
await submitToSession(sessionId, newMessage, currentMessages);
},
[sessionId, onFinish]
[sessionId, submitToSession]
);
const submitElicitationResponse = useCallback(
@@ -663,36 +879,10 @@ export function useChatStream({
dispatch({ type: 'SET_MESSAGES', payload: currentMessages });
dispatch({ type: 'START_STREAMING' });
abortControllerRef.current = new AbortController();
try {
const { stream } = await reply({
body: {
session_id: sessionId,
user_message: responseMessage,
},
throwOnError: true,
signal: abortControllerRef.current.signal,
sseMaxRetryAttempts: 0,
});
await streamFromResponse(
stream,
currentMessages,
dispatch,
onFinish,
sessionId,
abortControllerRef.current.signal
);
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
// Silently handle abort
} else {
onFinish('Submit error: ' + errorMessage(error));
}
}
await submitToSession(sessionId, responseMessage, currentMessages);
},
[sessionId, onFinish]
[sessionId, submitToSession]
);
const setRecipeUserParams = useCallback(
@@ -709,7 +899,6 @@ export function useChatStream({
},
throwOnError: true,
});
// TODO(Douwe): get this from the server instead of emulating it here
dispatch({
type: 'SET_SESSION',
payload: {
@@ -728,9 +917,6 @@ export function useChatStream({
);
useEffect(() => {
// This should happen on the server when the session is loaded or changed
// use session.id to support changing of sessions rather than depending on the
// stable sessionId.
if (state.session) {
updateFromSession({
body: {
@@ -742,7 +928,34 @@ export function useChatStream({
}, [state.session]);
const stopStreaming = useCallback(() => {
abortControllerRef.current?.abort();
const requestId = activeRequestIdRef.current;
const requestSessionId = activeRequestSessionIdRef.current;
// Abort the in-flight POST so the reply never starts if cancel wins the race
if (activeAbortRef.current) {
activeAbortRef.current.abort();
activeAbortRef.current = null;
}
if (requestId && requestSessionId) {
// Cancel against the session that originally started the request,
// not the current sessionId (which may have changed if user navigated).
sessionCancel({
path: { id: requestSessionId },
body: { request_id: requestId },
}).catch((e) => {
console.warn('Failed to cancel request:', e);
});
}
// Clean up listener
if (activeUnsubscribeRef.current) {
activeUnsubscribeRef.current();
activeUnsubscribeRef.current = null;
}
activeRequestIdRef.current = null;
activeRequestSessionIdRef.current = null;
dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Idle });
lastInteractionTimeRef.current = Date.now();
}, []);
@@ -810,34 +1023,7 @@ export function useChatStream({
dispatch({ type: 'SET_MESSAGES', payload: messagesForUI });
dispatch({ type: 'START_STREAMING' });
abortControllerRef.current = new AbortController();
try {
const { stream } = await reply({
body: {
session_id: targetSessionId,
user_message: updatedUserMessage,
},
throwOnError: true,
signal: abortControllerRef.current.signal,
sseMaxRetryAttempts: 0,
});
await streamFromResponse(
stream,
messagesForUI,
dispatch,
onFinish,
targetSessionId,
abortControllerRef.current.signal
);
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Idle });
} else {
throw error;
}
}
await submitToSession(targetSessionId, updatedUserMessage, messagesForUI);
} else {
await handleSubmit({ msg: newContent, images: [] });
}
@@ -853,7 +1039,7 @@ export function useChatStream({
});
}
},
[sessionId, handleSubmit, onFinish]
[sessionId, handleSubmit, submitToSession]
);
const setChatState = useCallback((newState: ChatState) => {
+196
View File
@@ -0,0 +1,196 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { sessionEvents, type MessageEvent } from '../api';
/**
* An SSE event with an optional request_id (added by the server at the
* SSE framing layer, not part of the generated MessageEvent type).
*/
export type SessionEvent = MessageEvent & {
request_id?: string;
/** Chat-level request UUID used for routing events to the correct handler. */
chat_request_id?: string;
};
type EventHandler = (event: SessionEvent) => void;
type ActiveRequestsHandler = (requestIds: string[]) => void;
export function useSessionEvents(sessionId: string) {
const listenersRef = useRef(new Map<string, Set<EventHandler>>());
const activeRequestsHandlerRef = useRef<ActiveRequestsHandler | null>(null);
const abortRef = useRef<AbortController | null>(null);
const [connected, setConnected] = useState(false);
useEffect(() => {
if (!sessionId) return;
const abortController = new AbortController();
abortRef.current = abortController;
(async () => {
let retryDelay = 500;
const MAX_RETRY_DELAY = 10_000;
const MAX_CONSECUTIVE_ERRORS = 10;
let consecutiveErrors = 0;
let lastEventId: string | undefined;
while (!abortController.signal.aborted) {
try {
const { stream } = await sessionEvents({
path: { id: sessionId },
signal: abortController.signal,
headers: lastEventId ? { 'Last-Event-ID': lastEventId } : undefined,
// Disable the inner retry loop so errors surface to our outer
// loop which tracks consecutive failures and notifies listeners.
sseMaxRetryAttempts: 1,
onSseEvent: (event) => {
if (event.id) {
lastEventId = event.id;
}
},
});
let receivedEvent = false;
for await (const event of stream) {
if (abortController.signal.aborted) break;
// Only mark as connected after the first real event arrives,
// since the HTTP request doesn't happen until iteration starts.
if (!receivedEvent) {
receivedEvent = true;
setConnected(true);
retryDelay = 500;
consecutiveErrors = 0;
}
// The server adds chat_request_id (the chat UUID) and request_id
// to the JSON at the SSE framing layer. Route using chat_request_id
// so that Notification events (which carry their own MCP tool-call
// request_id) still reach the correct handler.
const sessionEvent = event as SessionEvent;
const routingId = sessionEvent.chat_request_id ?? sessionEvent.request_id;
// ActiveRequests events notify the client about in-flight requests
// it can reattach to (e.g. after a remount).
if (sessionEvent.type === 'ActiveRequests') {
const ids = (sessionEvent as unknown as { request_ids: string[] }).request_ids;
activeRequestsHandlerRef.current?.(ids);
continue;
}
// Server-level errors without a request ID (e.g. "client too far
// behind") affect all active listeners — broadcast to everyone.
if (!routingId && sessionEvent.type === 'Error') {
for (const [id, handlers] of listenersRef.current) {
for (const handler of handlers) {
handler({ ...sessionEvent, request_id: id, chat_request_id: id });
}
}
} else if (routingId) {
const handlers = listenersRef.current.get(routingId);
if (handlers) {
for (const handler of handlers) {
handler(sessionEvent);
}
}
}
}
// Stream ended. Reconnect unless we were intentionally aborted.
if (abortController.signal.aborted) break;
setConnected(false);
// If the stream ended without delivering any events, the connection
// likely failed silently (e.g. 404 with sseMaxRetryAttempts: 1).
// Treat it as an error so backoff and error counting apply.
if (!receivedEvent) {
consecutiveErrors++;
console.warn(
`SSE stream ended with no events (${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS})`
);
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
console.error('SSE reconnect limit reached, notifying active listeners');
const errorEvent: SessionEvent = {
type: 'Error',
error: 'Lost connection to server',
} as SessionEvent;
for (const [routingId, handlers] of listenersRef.current) {
for (const handler of handlers) {
handler({ ...errorEvent, request_id: routingId, chat_request_id: routingId });
}
}
consecutiveErrors = 0;
}
await new Promise((r) => setTimeout(r, retryDelay));
retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY);
}
} catch (error) {
if (abortController.signal.aborted) break;
consecutiveErrors++;
console.warn(
`SSE connection error (${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS}), reconnecting:`,
error,
);
setConnected(false);
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
console.error('SSE reconnect limit reached, notifying active listeners');
// Send an error event to all active listeners so they can
// transition out of streaming state. Reset the counter so
// the loop keeps reconnecting for future requests.
const errorEvent: SessionEvent = {
type: 'Error',
error: 'Lost connection to server',
} as SessionEvent;
for (const [routingId, handlers] of listenersRef.current) {
for (const handler of handlers) {
handler({ ...errorEvent, request_id: routingId, chat_request_id: routingId });
}
}
consecutiveErrors = 0;
}
// Back off before retrying
await new Promise((r) => setTimeout(r, retryDelay));
retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY);
}
}
setConnected(false);
})();
const listeners = listenersRef.current;
return () => {
abortController.abort();
abortRef.current = null;
listeners.clear();
setConnected(false);
};
}, [sessionId]);
const addListener = useCallback(
(requestId: string, handler: EventHandler): (() => void) => {
if (!listenersRef.current.has(requestId)) {
listenersRef.current.set(requestId, new Set());
}
listenersRef.current.get(requestId)!.add(handler);
return () => {
const set = listenersRef.current.get(requestId);
if (set) {
set.delete(handler);
if (set.size === 0) {
listenersRef.current.delete(requestId);
}
}
};
},
[]
);
const setActiveRequestsHandler = useCallback((handler: ActiveRequestsHandler | null) => {
activeRequestsHandlerRef.current = handler;
}, []);
return { connected, addListener, setActiveRequestsHandler };
}