diff --git a/.github/workflows/reborn-tests.yml b/.github/workflows/reborn-tests.yml index a124bf7b03..aae3a94eee 100644 --- a/.github/workflows/reborn-tests.yml +++ b/.github/workflows/reborn-tests.yml @@ -750,12 +750,13 @@ jobs: if: github.event_name == 'pull_request' env: BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | set -euo pipefail python3 scripts/ci/reborn_changed_coverage.py \ --lcov reborn-integration-merged.lcov \ --base "$BASE_SHA" \ - --head "$(git rev-parse HEAD)" \ + --head "$HEAD_SHA" \ --threshold 85 \ --json reborn-changed-coverage.json \ | tee -a "$GITHUB_STEP_SUMMARY" diff --git a/crates/ironclaw_host_api/src/product_surface.rs b/crates/ironclaw_host_api/src/product_surface.rs index 02d76f2bab..4bb58ee80a 100644 --- a/crates/ironclaw_host_api/src/product_surface.rs +++ b/crates/ironclaw_host_api/src/product_surface.rs @@ -8,6 +8,7 @@ use std::sync::Mutex; use async_trait::async_trait; use serde::{Deserialize, Serialize}; +use tokio::sync::{Mutex as AsyncMutex, mpsc}; use crate::{ ActivityId, AdapterInstallationId, AgentId, CapabilityId, ChannelInboundClassification, @@ -171,11 +172,55 @@ pub struct ProductSurfaceStreamRequest { } /// Generic product event stream response. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Serialize, Deserialize)] pub struct ProductSurfaceStreamResponse { pub events: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub next_cursor: Option, + /// Optional continuation of this same logical stream. + /// + /// This handle is process-local transport state and is intentionally + /// omitted from the serialized response shape. + #[serde(skip)] + pub subscription: Option, +} + +/// One continuous, caller-scoped product event subscription. +/// +/// HTTP transports keep this receiver alive for the lifetime of the client +/// connection. This avoids the event-loss window created by repeatedly +/// tearing down and recreating one-event subscriptions. +pub struct ProductSurfaceEventSubscription { + receiver: + Arc>>>, +} + +impl ProductSurfaceEventSubscription { + pub fn new( + receiver: mpsc::Receiver>, + ) -> Self { + Self { + receiver: Arc::new(AsyncMutex::new(receiver)), + } + } + + pub async fn next(&self) -> Option> { + self.receiver.lock().await.recv().await + } +} + +impl std::fmt::Debug for ProductSurfaceEventSubscription { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ProductSurfaceEventSubscription") + .finish_non_exhaustive() + } +} + +impl PartialEq for ProductSurfaceEventSubscription { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.receiver, &other.receiver) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -574,6 +619,12 @@ mod tests { use super::*; use crate::{AgentId, ProjectId, TenantId, UserId}; + #[test] + fn product_stream_continuation_is_single_consumer() { + static_assertions::assert_not_impl_any!(ProductSurfaceEventSubscription: Clone); + static_assertions::assert_not_impl_any!(ProductSurfaceStreamResponse: Clone); + } + fn caller() -> ProductSurfaceCaller { ProductSurfaceCaller::new( TenantId::new("tenant-alpha").expect("tenant"), diff --git a/crates/ironclaw_host_api/tests/host_api_contract.rs b/crates/ironclaw_host_api/tests/host_api_contract.rs index 6fe12e0794..9787553c97 100644 --- a/crates/ironclaw_host_api/tests/host_api_contract.rs +++ b/crates/ironclaw_host_api/tests/host_api_contract.rs @@ -1689,3 +1689,41 @@ fn dispatch_error_auth_required_debug_redacts_required_secrets() { "provider id must not appear in Debug output; got: {debug_with_requirement}" ); } + +#[test] +fn product_stream_continuation_is_process_local_not_wire_state() { + let (_sender, receiver) = tokio::sync::mpsc::channel(1); + let response = ProductSurfaceStreamResponse { + events: vec![json!({"kind": "projection_update"})], + next_cursor: Some("cursor-1".to_string()), + subscription: Some(ProductSurfaceEventSubscription::new(receiver)), + }; + + let encoded = serde_json::to_value(response).expect("stream response serializes"); + assert_eq!( + encoded, + json!({ + "events": [{"kind": "projection_update"}], + "next_cursor": "cursor-1" + }), + "the in-process continuation handle must never cross the wire" + ); + let decoded: ProductSurfaceStreamResponse = + serde_json::from_value(encoded).expect("wire response deserializes"); + assert!(decoded.subscription.is_none()); +} + +#[test] +fn product_stream_continuation_debug_and_identity_are_stable() { + let (_sender, receiver) = tokio::sync::mpsc::channel(1); + let subscription = ProductSurfaceEventSubscription::new(receiver); + let (_other_sender, other_receiver) = tokio::sync::mpsc::channel(1); + let other_subscription = ProductSurfaceEventSubscription::new(other_receiver); + + assert_eq!(subscription, subscription); + assert_ne!(subscription, other_subscription); + assert_eq!( + format!("{subscription:?}"), + "ProductSurfaceEventSubscription { .. }" + ); +} diff --git a/crates/ironclaw_llm/src/nearai_chat.rs b/crates/ironclaw_llm/src/nearai_chat.rs index 3ac5591e5a..bfb46ed2a7 100644 --- a/crates/ironclaw_llm/src/nearai_chat.rs +++ b/crates/ironclaw_llm/src/nearai_chat.rs @@ -5,6 +5,7 @@ //! - **API key auth**: When `NEARAI_API_KEY` is set, uses Bearer API key //! - **Session token auth**: Otherwise, uses `SessionManager` for Bearer session token //! with automatic renewal on 401 errors +// arch-exempt: large_file, provider-local streaming regression tests require private parser access pending provider adapter decomposition, plan #6175 use std::collections::HashMap; use std::sync::Arc; @@ -1909,6 +1910,69 @@ mod tests { .await } + async fn complete_search_tool_streaming_from_sse( + sse_body: &'static str, + ) -> ToolCompletionResponse { + use tokio::io::AsyncWriteExt; + use tokio::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let server_task = tokio::spawn(async move { + let (mut socket, _) = accept_chat_request(&listener).await; + socket + .write_all( + b"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncache-control: no-cache\r\nconnection: close\r\n\r\n", + ) + .await + .expect("write sse headers"); + socket + .write_all(sse_body.as_bytes()) + .await + .expect("write sse body"); + }); + + let provider = NearAiChatProvider::new(test_nearai_config(&base_url), test_session()) + .expect("provider"); + let response = complete_search_tool_streaming(provider) + .await + .expect("streaming completion"); + server_task.await.expect("server task"); + response + } + + #[tokio::test] + async fn complete_with_tools_streaming_infers_tool_use_without_finish_reason() { + let response = complete_search_tool_streaming_from_sse( + r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_search","type":"function","function":{"name":"search","arguments":"{\"query\":\"near ai\"}"}}]},"finish_reason":null}]} + +data: [DONE] + +"#, + ) + .await; + + assert_eq!(response.finish_reason, FinishReason::ToolUse); + assert_eq!(response.tool_calls.len(), 1); + assert_eq!(response.tool_calls[0].name, "search"); + } + + #[tokio::test] + async fn complete_with_tools_streaming_preserves_unknown_finish_reason() { + let response = complete_search_tool_streaming_from_sse( + r#"data: {"choices":[{"delta":{"content":"answer"},"finish_reason":"vendor_specific"}]} + +data: [DONE] + +"#, + ) + .await; + + assert_eq!(response.finish_reason, FinishReason::Unknown); + assert_eq!(response.content.as_deref(), Some("answer")); + assert!(response.tool_calls.is_empty()); + } + #[tokio::test] async fn complete_streaming_emits_delta_before_response_completes() { use tokio::io::AsyncWriteExt; diff --git a/crates/ironclaw_product/src/projection/live_progress.rs b/crates/ironclaw_product/src/projection/live_progress.rs index c1b2b5050b..407feb677a 100644 --- a/crates/ironclaw_product/src/projection/live_progress.rs +++ b/crates/ironclaw_product/src/projection/live_progress.rs @@ -1,7 +1,7 @@ use std::{ collections::HashMap, sync::{ - Arc, Mutex, MutexGuard, + Arc, Mutex, MutexGuard, RwLock, atomic::{AtomicBool, AtomicU64, Ordering}, }, time::Duration, @@ -40,12 +40,21 @@ use ironclaw_turns::{ // same live broadcast would put low append-log cursors and high synthetic // cursors behind the same `last_delivered_cursor` ordering gate. const LIVE_PROGRESS_CURSOR_BASE: u64 = 1 << 62; -const LIVE_TEXT_COALESCE_WINDOW: Duration = Duration::from_millis(75); +// Cumulative model text is replaceable state, not a lossless event log. Bound +// publication to one browser paint interval so provider bursts cannot overrun +// projection subscribers while ordinary 20-50 ms provider cadence passes +// through unchanged. +const LIVE_TEXT_COALESCE_WINDOW: Duration = Duration::from_millis(16); +// The runtime's concurrent-run limit is substantially lower. Keep this +// defensive ceiling so a missing terminal milestone cannot turn phase +// bookkeeping into unbounded process memory. +const MAX_TRACKED_TEXT_PHASE_RUNS: usize = 1_024; pub(super) struct LiveProgressMilestoneSink { inner: Arc, publisher: Arc, text_coalescer: Arc, + text_phase_by_run: RwLock>, } #[derive(Debug)] @@ -65,9 +74,6 @@ pub struct LiveProjectionPublisher { struct LiveTextProjectionCoalescer { publisher: Arc, next_generation: AtomicU64, - // State mutation and channel publication are separate critical sections. - // This guard preserves their relative order without holding `states` while - // the broadcast source wakes subscribers. publication_order: Mutex<()>, states: Mutex>, } @@ -83,6 +89,7 @@ struct PendingTextProjection { owner: Option, scope: TurnScope, run_id: TurnRunId, + id: String, body: String, } @@ -104,6 +111,171 @@ impl LiveProgressMilestoneSink { inner, text_coalescer: Arc::new(LiveTextProjectionCoalescer::new(Arc::clone(&publisher))), publisher, + text_phase_by_run: RwLock::new(HashMap::new()), + } + } + + fn begin_text_phase(&self, run_id: TurnRunId) { + let mut phases = match self.text_phase_by_run.write() { + Ok(phases) => phases, + Err(poisoned) => poisoned.into_inner(), + }; + if !phases.contains_key(&run_id) && phases.len() >= MAX_TRACKED_TEXT_PHASE_RUNS { + tracing::debug!( + %run_id, + "live text phase tracker reached its bounded capacity" + ); + return; + } + let next_phase = phases + .get(&run_id) + .copied() + .unwrap_or_default() + .saturating_add(1); + phases.insert(run_id, next_phase); + } + + fn text_id(&self, run_id: TurnRunId) -> String { + let phases = match self.text_phase_by_run.read() { + Ok(phases) => phases, + Err(poisoned) => poisoned.into_inner(), + }; + match phases.get(&run_id) { + Some(phase) => text_phase_id(run_id, *phase), + None => legacy_text_id(run_id), + } + } + + fn finish_run(&self, run_id: TurnRunId) { + let mut phases = match self.text_phase_by_run.write() { + Ok(phases) => phases, + Err(poisoned) => poisoned.into_inner(), + }; + phases.remove(&run_id); + } +} + +impl LiveTextProjectionCoalescer { + fn new(publisher: Arc) -> Self { + Self { + publisher, + next_generation: AtomicU64::new(0), + publication_order: Mutex::new(()), + states: Mutex::new(HashMap::new()), + } + } + + fn submit(self: &Arc, projection: PendingTextProjection) { + let run_id = projection.run_id; + let mut timer = None; + let mut publish_projection = None; + { + let _publication_order = self.lock_publication_order(); + { + let mut states = self.lock_states(); + match states.get_mut(&run_id) { + Some(state) => { + state.pending = Some(projection); + if !state.timer_scheduled { + state.timer_scheduled = true; + timer = Some(( + state.generation, + state.last_published_at + LIVE_TEXT_COALESCE_WINDOW, + )); + } + } + None => { + if states.len() >= MAX_TRACKED_TEXT_PHASE_RUNS { + drop(states); + self.publish(projection); + return; + } + let generation = self.next_generation.fetch_add(1, Ordering::Relaxed) + 1; + states.insert( + run_id, + LiveTextProjectionState { + generation, + last_published_at: tokio::time::Instant::now(), + pending: None, + timer_scheduled: false, + }, + ); + publish_projection = Some(projection); + } + } + } + if let Some(projection) = publish_projection.take() { + self.publish(projection); + } + } + + if let Some((generation, deadline)) = timer { + let coalescer = Arc::clone(self); + tokio::spawn(async move { + tokio::time::sleep_until(deadline).await; + coalescer.flush_timer(run_id, generation); + }); + } + } + + fn flush_boundary(&self, run_id: TurnRunId) { + let _publication_order = self.lock_publication_order(); + let projection = { + let mut states = self.lock_states(); + states.remove(&run_id).and_then(|state| state.pending) + }; + if let Some(projection) = projection { + self.publish(projection); + } + } + + fn flush_timer(&self, run_id: TurnRunId, generation: u64) { + let _publication_order = self.lock_publication_order(); + let projection = { + let mut states = self.lock_states(); + let Some(state) = states.get_mut(&run_id) else { + return; + }; + if state.generation != generation { + return; + } + state.timer_scheduled = false; + let projection = state.pending.take(); + if projection.is_some() { + state.last_published_at = tokio::time::Instant::now(); + } + projection + }; + if let Some(projection) = projection { + self.publish(projection); + } + } + + fn publish(&self, projection: PendingTextProjection) { + let sequence = self.publisher.next_live_sequence(); + self.publisher.publish_live_item( + projection.owner.as_ref(), + &projection.scope, + sequence, + ThreadLiveProjectionItem::Text { + id: projection.id, + run_id: projection.run_id, + body: projection.body, + }, + ); + } + + fn lock_states(&self) -> MutexGuard<'_, HashMap> { + match self.states.lock() { + Ok(states) => states, + Err(poisoned) => poisoned.into_inner(), + } + } + + fn lock_publication_order(&self) -> MutexGuard<'_, ()> { + match self.publication_order.lock() { + Ok(order) => order, + Err(poisoned) => poisoned.into_inner(), } } } @@ -243,138 +415,6 @@ impl LiveProjectionPublisher { } } -impl LiveTextProjectionCoalescer { - fn new(publisher: Arc) -> Self { - Self { - publisher, - next_generation: AtomicU64::new(0), - publication_order: Mutex::new(()), - states: Mutex::new(HashMap::new()), - } - } - - fn submit(self: &Arc, projection: PendingTextProjection) { - // ModelTextDelta carries the full cumulative assistant text, so an - // intermediate replacement is redundant. Keep this policy here rather - // than in the generic stream manager, whose other items are lossless. - let run_id = projection.run_id; - let mut timer = None; - let mut publish_projection = None; - { - let _publication_order = self.lock_publication_order(); - { - let mut states = self.lock_states(); - match states.get_mut(&run_id) { - Some(state) => { - state.pending = Some(projection); - if !state.timer_scheduled { - state.timer_scheduled = true; - timer = Some(( - state.generation, - state.last_published_at + LIVE_TEXT_COALESCE_WINDOW, - )); - } - } - None => { - let generation = self.next_generation.fetch_add(1, Ordering::Relaxed) + 1; - states.insert( - run_id, - LiveTextProjectionState { - generation, - last_published_at: tokio::time::Instant::now(), - pending: None, - timer_scheduled: false, - }, - ); - publish_projection = Some(projection); - } - } - } - if let Some(projection) = publish_projection.take() { - self.publish(projection); - } - } - - if let Some((generation, deadline)) = timer { - let coalescer = Arc::clone(self); - tokio::spawn(async move { - tokio::time::sleep_until(deadline).await; - coalescer.flush_timer(run_id, generation); - }); - } - } - - fn flush_boundary(&self, run_id: TurnRunId) { - let _publication_order = self.lock_publication_order(); - let projection = { - let mut states = self.lock_states(); - states.remove(&run_id).and_then(|state| state.pending) - }; - if let Some(projection) = projection { - self.publish(projection); - } - } - - fn flush_timer(&self, run_id: TurnRunId, generation: u64) { - let _publication_order = self.lock_publication_order(); - let projection = { - let mut states = self.lock_states(); - let Some(state) = states.get_mut(&run_id) else { - return; - }; - if state.generation != generation { - return; - } - - state.timer_scheduled = false; - let projection = state.pending.take(); - if projection.is_some() { - state.last_published_at = tokio::time::Instant::now(); - } - projection - }; - if let Some(projection) = projection { - self.publish(projection); - } - } - - fn publish(&self, projection: PendingTextProjection) { - let sequence = self.publisher.next_live_sequence(); - self.publisher.publish_live_item( - projection.owner.as_ref(), - &projection.scope, - sequence, - ThreadLiveProjectionItem::Text { - id: text_id(projection.run_id), - run_id: projection.run_id, - body: projection.body, - }, - ); - } - - fn lock_states(&self) -> MutexGuard<'_, HashMap> { - match self.states.lock() { - Ok(states) => states, - Err(poisoned) => { - tracing::debug!("live text projection coalescer lock recovered after panic"); - self.states.clear_poison(); - poisoned.into_inner() - } - } - } - - fn lock_publication_order(&self) -> MutexGuard<'_, ()> { - match self.publication_order.lock() { - Ok(order) => order, - Err(poisoned) => { - tracing::debug!("live text projection publication lock recovered after panic"); - self.publication_order.clear_poison(); - poisoned.into_inner() - } - } - } -} - pub(super) fn product_items_for_live_update( display_previews: &dyn super::display_preview::CapabilityDisplayPreviewSource, update: &ThreadLiveProjectionUpdate, @@ -487,6 +527,7 @@ impl LiveProgressMilestoneSink { owner: milestone.actor.as_ref().map(|actor| actor.user_id.clone()), scope: milestone.scope.clone(), run_id: milestone.run_id, + id: self.text_id(milestone.run_id), body, }); } @@ -630,6 +671,9 @@ impl LoopHostMilestoneSink for LiveProgressMilestoneSink { self.text_coalescer.flush_boundary(milestone.run_id); } match &milestone.kind { + LoopHostMilestoneKind::ModelStarted { .. } => { + self.begin_text_phase(milestone.run_id); + } LoopHostMilestoneKind::ModelTextDelta { safe_text } => { self.publish_text_delta(&milestone, safe_text); } @@ -696,6 +740,9 @@ impl LoopHostMilestoneSink for LiveProgressMilestoneSink { LoopHostMilestoneKind::DriverNote { kind, safe_summary } => { self.publish_work_summary(&milestone, *kind, safe_summary.as_str()); } + LoopHostMilestoneKind::Completed { .. } | LoopHostMilestoneKind::Failed { .. } => { + self.finish_run(milestone.run_id); + } _ => {} } Ok(()) @@ -736,10 +783,14 @@ fn thinking_id(run_id: TurnRunId, sequence: u64) -> String { format!("thinking:{run_id}:{sequence}") } -fn text_id(run_id: TurnRunId) -> String { +fn legacy_text_id(run_id: TurnRunId) -> String { format!("text:{run_id}") } +fn text_phase_id(run_id: TurnRunId, phase: u64) -> String { + format!("text:{run_id}:{phase}") +} + fn work_summary_id(run_id: TurnRunId, sequence: u64) -> String { format!("work-summary:{run_id}:{sequence}") } diff --git a/crates/ironclaw_product/src/projection/tests/live_progress_stream.rs b/crates/ironclaw_product/src/projection/tests/live_progress_stream.rs index dc9726fc2d..36044036c6 100644 --- a/crates/ironclaw_product/src/projection/tests/live_progress_stream.rs +++ b/crates/ironclaw_product/src/projection/tests/live_progress_stream.rs @@ -1,7 +1,7 @@ use super::*; use ironclaw_host_api::RuntimeKind; use ironclaw_turns::{ - CapabilityActivityId, TurnId, + CapabilityActivityId, LoopCompletionKind, LoopExitId, TurnId, run_profile::{ InMemoryLoopHostMilestoneSink, LoopDriverId, LoopHostMilestone, LoopHostMilestoneKind, LoopHostMilestoneSink, LoopSafeSummary, @@ -285,10 +285,112 @@ async fn fresh_product_event_stream_compacts_buffered_assistant_text_to_latest_s } #[tokio::test] -async fn live_assistant_text_coalescer_flushes_latest_update_on_timer() { - let fixture = live_projection_fixture("webui-text-timer"); +async fn fresh_product_event_stream_preserves_text_phases_and_clears_terminal_run() { + let fixture = live_projection_fixture("webui-text-phases"); let scope = fixture.scope.clone(); let run_id = TurnRunId::new(); + let milestone = |kind| LoopHostMilestone { + scope: scope.clone(), + actor: None, + turn_id: TurnId::new(), + run_id, + loop_driver_id: LoopDriverId::new("test_loop").unwrap(), + kind, + }; + + for kind in [ + LoopHostMilestoneKind::ModelStarted { + requested_model_profile_id: None, + }, + LoopHostMilestoneKind::ModelTextDelta { + safe_text: "I’ll research".to_string(), + }, + LoopHostMilestoneKind::ModelTextDelta { + safe_text: "I’ll research this first.".to_string(), + }, + LoopHostMilestoneKind::ModelCompleted { + effective_model_profile_id: ironclaw_turns::run_profile::ModelProfileId::new( + "test-model", + ) + .unwrap(), + }, + LoopHostMilestoneKind::ModelStarted { + requested_model_profile_id: None, + }, + LoopHostMilestoneKind::ModelTextDelta { + safe_text: "Here is the final answer.".to_string(), + }, + LoopHostMilestoneKind::Completed { + completion_kind: LoopCompletionKind::FinalReply, + exit_id: LoopExitId::new("exit:webui-text-phases").unwrap(), + }, + LoopHostMilestoneKind::ModelTextDelta { + safe_text: "Unexpected trailing text.".to_string(), + }, + ] { + fixture + .sink + .publish_loop_milestone(milestone(kind)) + .await + .unwrap(); + } + + let events = fixture + .services + .product_event_stream() + .drain(ProjectionSubscriptionRequest { + actor: TurnActor::new(fixture.user_id), + scope, + after_cursor: None, + }) + .await + .unwrap(); + let text_items = events + .iter() + .flat_map(|event| match event.payload() { + ProductOutboundPayload::ProjectionUpdate { state } => state + .items + .iter() + .filter_map(|item| match item { + ProductProjectionItem::Text { + id, + run_id: observed_run_id, + body, + } if *observed_run_id == Some(run_id) => Some((id.clone(), body.clone())), + _ => None, + }) + .collect::>(), + _ => Vec::new(), + }) + .collect::>(); + + assert_eq!( + text_items, + vec![ + ( + format!("text:{run_id}:1"), + "I’ll research this first.".to_string() + ), + ( + format!("text:{run_id}:2"), + "Here is the final answer.".to_string() + ), + ( + format!("text:{run_id}"), + "Unexpected trailing text.".to_string() + ), + ], + "model phases must remain distinct and terminal milestones must clear phase state" + ); +} + +#[tokio::test] +async fn provider_cadence_text_updates_are_not_visibly_batched() { + let fixture = live_projection_fixture("webui-text-cadence"); + let scope = fixture.scope.clone(); + let run_id = TurnRunId::new(); + let capability_id = CapabilityId::new("builtin.http").unwrap(); + let activity_id = CapabilityActivityId::new(); let mut subscription = fixture .services .product_event_stream() @@ -299,51 +401,68 @@ async fn live_assistant_text_coalescer_flushes_latest_update_on_timer() { }) .await .unwrap(); - let milestone = |safe_text| LoopHostMilestone { + + let milestone = |kind| LoopHostMilestone { scope: scope.clone(), actor: None, turn_id: TurnId::new(), run_id, loop_driver_id: LoopDriverId::new("test_loop").unwrap(), - kind: LoopHostMilestoneKind::ModelTextDelta { safe_text }, + kind, }; + for body in ["first", "second", "third"] { + fixture + .sink + .publish_loop_milestone(milestone(LoopHostMilestoneKind::ModelTextDelta { + safe_text: body.to_string(), + })) + .await + .unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } fixture .sink - .publish_loop_milestone(milestone("first".to_string())) - .await - .unwrap(); - fixture - .sink - .publish_loop_milestone(milestone("latest".to_string())) + .publish_loop_milestone(milestone(LoopHostMilestoneKind::CapabilityInvoked { + activity_id, + capability_id, + })) .await .unwrap(); let mut text_bodies = Vec::new(); - for _ in 0..2 { + for _ in 0..8 { let envelope = tokio::time::timeout(std::time::Duration::from_secs(1), subscription.next()) .await - .expect("coalesced live projection event") + .expect("live projection event") .expect("live projection subscription remains open") .expect("live projection event remains valid"); let ProductOutboundPayload::ProjectionUpdate { state } = envelope.payload() else { continue; }; - text_bodies.extend(state.items.iter().filter_map(|item| match item { - ProductProjectionItem::Text { - run_id: observed_run_id, - body, - .. - } if *observed_run_id == Some(run_id) => Some(body.clone()), - _ => None, - })); + for item in &state.items { + match item { + ProductProjectionItem::Text { + run_id: observed_run_id, + body, + .. + } if *observed_run_id == Some(run_id) => text_bodies.push(body.clone()), + ProductProjectionItem::CapabilityActivity(activity) + if activity.invocation_id == InvocationId::from_uuid(activity_id.as_uuid()) => + { + assert_eq!(text_bodies, ["first", "second", "third"]); + return; + } + _ => {} + } + } } - assert_eq!(text_bodies, vec!["first", "latest"]); + panic!("tool activity did not follow provider-cadence text"); } #[tokio::test] -async fn live_assistant_text_burst_stays_subscribed_and_flushes_before_tool_activity() { +async fn live_text_microburst_keeps_latest_snapshot_and_precedes_tool_activity() { let fixture = live_projection_fixture("webui-text-burst"); let scope = fixture.scope.clone(); let run_id = TurnRunId::new(); @@ -433,11 +552,11 @@ async fn live_assistant_text_burst_stays_subscribed_and_flushes_before_tool_acti assert_eq!( text_bodies.last().map(String::as_str), Some("partial answer 63"), - "the tool boundary must flush the latest cumulative assistant text" + "the latest cumulative assistant text must precede tool activity" ); assert!( text_bodies.len() <= 3, - "the 64-update burst should be coalesced before delivery: {text_bodies:#?}" + "the 64-update microburst should keep only paint-relevant cumulative snapshots: {text_bodies:#?}" ); } diff --git a/crates/ironclaw_product/src/reborn_services.rs b/crates/ironclaw_product/src/reborn_services.rs index 74855f0523..e8170058a4 100644 --- a/crates/ironclaw_product/src/reborn_services.rs +++ b/crates/ironclaw_product/src/reborn_services.rs @@ -49,7 +49,7 @@ use ironclaw_turns::{ }; use secrecy::{ExposeSecret as _, SecretString}; use serde::{Deserialize, Serialize, de::DeserializeOwned}; -use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard}; +use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard, mpsc}; use url::Url; use uuid::Uuid; @@ -550,6 +550,8 @@ const OPERATOR_LOG_CONTEXT_TRUNCATED_SUFFIX: &str = " ... [truncated]"; const NOTICE_BLOCKED_APPROVAL: &str = "An approval gate is open on this thread — resolve it (approve or deny) before continuing, then resend your message."; const NOTICE_BLOCKED_AUTH: &str = "An authentication gate is open on this thread — complete authentication before continuing, then resend your message."; const NOTICE_BUSY_GENERIC: &str = "Ironclaw is still working on a previous message — resend yours once the current task finishes."; +const PRODUCT_STREAM_FIRST_EVENT_WAIT: Duration = Duration::from_secs(1); +const PRODUCT_STREAM_ACCESS_REVALIDATION_INTERVAL: Duration = Duration::from_secs(1); fn command_result_field(label: &str, value: impl Into) -> CommandResultField { CommandResultField { @@ -4367,22 +4369,8 @@ where caller: ProductSurfaceCaller, request: RebornStreamEventsRequest, ) -> Result { - let thread_id = parse_thread_id_field("thread_id", request.thread_id)?; - let actor = caller.actor(); - // Ownership probe: the SSE handler calls stream_events once per poll, - // so the cheap read_thread probe is used rather than loading the full - // transcript. Without it a caller sharing (tenant, agent, project) - // could read another user's projection feed by guessing thread_id. - // The automation fallback allows the owner of an automation to stream - // events for a trigger-fired thread (which is stored under the trigger - // creator). The returned scope may contain an explicit owner for - // trigger threads. - // - // Authorization is revalidated on every poll — no caching — so a - // caller that loses automation visibility between polls cannot keep - // draining the trigger-owned stream. - let access = self - .resolve_thread_access_for_caller(caller.clone(), caller.turn_scope(thread_id), &actor) + let (_, subscription_request) = self + .resolve_projection_subscription_request(caller, request) .await?; let Some(event_stream) = &self.event_stream else { return Err(ProductSurfaceError::from_status_kind( @@ -4392,21 +4380,8 @@ where false, )); }; - // Projection identity must be the thread owner, not necessarily the - // caller. Turn events and the runtime event stream are keyed under the - // identity of the actor that submitted the run (the trigger creator for - // trigger threads; the session user for normal threads). The caller - // already proved visibility via automation ownership above; using the - // caller's id here would filter to the wrong stream/events. - // - // For normal session threads `explicit_owner_user_id()` is `None` and - // we fall back to the caller's id — behaviour is unchanged. let events = event_stream - .drain(ProjectionSubscriptionRequest { - actor: access.run_actor, - scope: access.scope, - after_cursor: request.after_cursor, - }) + .drain(subscription_request) .await .map_err(map_projection_error)?; Ok(RebornStreamEventsResponse { events }) @@ -4854,46 +4829,183 @@ where ironclaw_host_api::ProductSurfaceStreamResponse, ironclaw_host_api::ProductSurfaceError, > { - let thread_id = request.stream_id.ok_or_else(|| { - ironclaw_host_api::ProductSurfaceError::from_status( - ironclaw_host_api::ProductSurfaceErrorCode::InvalidRequest, - 400, - false, - ) - })?; - let after_cursor = match request.after_cursor { - Some(cursor) => Some(ProjectionCursor::new(cursor).map_err(|_| { - ironclaw_host_api::ProductSurfaceError::from_status( - ironclaw_host_api::ProductSurfaceErrorCode::InvalidRequest, - 400, - false, - ) - })?), - None => None, - }; - let response = RebornServices::stream_events( - self, - caller, - RebornStreamEventsRequest { - thread_id, - after_cursor, - }, - ) + let request = decode_product_surface_stream_request(request)?; + if self + .event_stream + .as_ref() + .is_some_and(|event_stream| event_stream.supports_subscription()) + { + let subscription = + open_product_surface_event_subscription(self, caller, request).await?; + return match tokio::time::timeout(PRODUCT_STREAM_FIRST_EVENT_WAIT, subscription.next()) + .await + { + Ok(Some(Ok(mut response))) => { + response.subscription = Some(subscription); + Ok(response) + } + Ok(Some(Err(error))) => Err(error), + Ok(None) | Err(_) => Ok(ironclaw_host_api::ProductSurfaceStreamResponse { + events: Vec::new(), + next_cursor: None, + subscription: None, + }), + }; + } + let response = RebornServices::stream_events(self, caller, request).await?; + encode_product_surface_stream_response(response) + } +} + +async fn open_product_surface_event_subscription( + services: &RebornServices, + caller: ProductSurfaceCaller, + request: RebornStreamEventsRequest, +) -> Result +where + I: ProductCapabilityInvoker + Clone + 'static, + V: RebornViewProvider + Clone + 'static, +{ + let (access, subscription_request) = services + .resolve_projection_subscription_request(caller.clone(), request) .await?; - let events = response - .events - .into_iter() - .map(serde_json::to_value) - .collect::, _>>() - .map_err(|error| { - tracing::error!(%error, "failed to encode product surface stream response"); - ironclaw_host_api::ProductSurfaceError::internal() - })?; - Ok(ironclaw_host_api::ProductSurfaceStreamResponse { - events, - next_cursor: None, - }) + let thread_id = subscription_request.scope.thread_id.clone(); + let actor = caller.actor(); + let Some(event_stream) = &services.event_stream else { + return Err(ProductSurfaceError::from_status_kind( + ProductSurfaceErrorCode::Unavailable, + ProductSurfaceErrorKind::ReplayUnavailable, + 503, + false, + )); + }; + if !event_stream.supports_subscription() { + return Err(ProductSurfaceError::service_unavailable(false)); } + + let subscribed_actor = access.run_actor.clone(); + let subscribed_scope = access.scope.clone(); + let mut subscription = event_stream + .subscribe(subscription_request) + .await + .map_err(map_projection_error)?; + // A single-slot handoff applies backpressure without creating another + // burst queue. The underlying projection subscription remains alive + // continuously, so live milestones cannot fall between resubscriptions. + let (sender, receiver) = mpsc::channel(1); + let (access_error_sender, mut access_error_receiver) = mpsc::channel(1); + let services = services.clone(); + let access_caller = caller.clone(); + let access_thread_id = thread_id.clone(); + let access_actor = actor.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(PRODUCT_STREAM_ACCESS_REVALIDATION_INTERVAL); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + // The subscription-open probe is the initial validation. The first + // periodic probe should run one interval later, not immediately. + interval.tick().await; + loop { + tokio::select! { + _ = access_error_sender.closed() => return, + _ = interval.tick() => {} + } + let revalidated = services + .resolve_thread_access_for_caller( + access_caller.clone(), + access_caller.turn_scope(access_thread_id.clone()), + &access_actor, + ) + .await; + let error = match revalidated { + Ok(access) + if access.run_actor == subscribed_actor && access.scope == subscribed_scope => + { + continue; + } + Ok(_) => ProductSurfaceError::not_found(), + Err(error) => error, + }; + let _ = access_error_sender.send(error).await; + return; + } + }); + tokio::spawn(async move { + loop { + // Reserve the one output slot before reading another source + // event. A slow browser therefore backpressures this bridge + // instead of accumulating another burst queue. + let permit = tokio::select! { + _ = sender.closed() => return, + permit = sender.reserve() => match permit { + Ok(permit) => permit, + Err(_) => return, + }, + }; + let item = tokio::select! { + biased; + _ = sender.closed() => return, + error = access_error_receiver.recv() => { + permit.send(Err(error.unwrap_or_else(ProductSurfaceError::internal))); + return; + } + item = subscription.next() => item, + }; + let Some(item) = item else { + return; + }; + let response = match item { + Ok(event) => encode_product_surface_stream_response(RebornStreamEventsResponse { + events: vec![event], + }), + Err(error) => Err(map_projection_error(error)), + }; + let stop = response.is_err(); + permit.send(response); + if stop { + return; + } + } + }); + Ok(ironclaw_host_api::ProductSurfaceEventSubscription::new( + receiver, + )) +} + +fn decode_product_surface_stream_request( + request: ironclaw_host_api::ProductSurfaceStreamRequest, +) -> Result { + let thread_id = request.stream_id.ok_or_else(|| { + ProductSurfaceError::from_status(ProductSurfaceErrorCode::InvalidRequest, 400, false) + })?; + let after_cursor = match request.after_cursor { + Some(cursor) => Some(ProjectionCursor::new(cursor).map_err(|_| { + ProductSurfaceError::from_status(ProductSurfaceErrorCode::InvalidRequest, 400, false) + })?), + None => None, + }; + Ok(RebornStreamEventsRequest { + thread_id, + after_cursor, + }) +} + +fn encode_product_surface_stream_response( + response: RebornStreamEventsResponse, +) -> Result { + let events = response + .events + .into_iter() + .map(serde_json::to_value) + .collect::, _>>() + .map_err(|error| { + tracing::error!(%error, "failed to encode product surface stream response"); + ProductSurfaceError::internal() + })?; + Ok(ironclaw_host_api::ProductSurfaceStreamResponse { + events, + next_cursor: None, + subscription: None, + }) } impl RebornServices @@ -5730,6 +5842,30 @@ where } } + async fn resolve_projection_subscription_request( + &self, + caller: ProductSurfaceCaller, + request: RebornStreamEventsRequest, + ) -> Result<(ResolvedThreadAccess, ProjectionSubscriptionRequest), ProductSurfaceError> { + let thread_id = parse_thread_id_field("thread_id", request.thread_id)?; + let actor = caller.actor(); + // Use the cheap ownership probe rather than loading the full + // transcript. The automation fallback returns the trigger creator's + // scope and actor when the caller owns that automation. + let access = self + .resolve_thread_access_for_caller(caller.clone(), caller.turn_scope(thread_id), &actor) + .await?; + // Projection identity is the actor that submitted the run, not + // necessarily the browser caller. The authorization probe above is + // the authority boundary for both one-shot drains and subscriptions. + let subscription_request = ProjectionSubscriptionRequest { + actor: access.run_actor.clone(), + scope: access.scope.clone(), + after_cursor: request.after_cursor, + }; + Ok((access, subscription_request)) + } + fn require_project_filesystem( &self, ) -> Result<&Arc, ProductSurfaceError> { diff --git a/crates/ironclaw_product/tests/reborn_services_contract.rs b/crates/ironclaw_product/tests/reborn_services_contract.rs index 41bdd49cfb..e04cdada63 100644 --- a/crates/ironclaw_product/tests/reborn_services_contract.rs +++ b/crates/ironclaw_product/tests/reborn_services_contract.rs @@ -35,7 +35,7 @@ use ironclaw_host_api::{ use ironclaw_host_api::{ CapabilitySurfaceKind, InstallationState, LifecyclePublicState, ProductSurface, ProductSurfaceCaller, ProductSurfaceError, ProductSurfaceErrorCode, ProductSurfaceErrorKind, - ProductSurfaceInvokeRequest, ProductSurfaceValidationCode, + ProductSurfaceInvokeRequest, ProductSurfaceStreamRequest, ProductSurfaceValidationCode, }; use ironclaw_product::{ ADMIN_USER_DELETE_CAPABILITY_ID, ADMIN_USER_DELETE_SECRET_CAPABILITY_ID, @@ -127,6 +127,12 @@ use ironclaw_product::{ TriggerRunThreadScope, UpsertLlmProviderRequest, approval_gate_ref, automation_trigger_thread_metadata_json, }; +use ironclaw_product::{ + AdapterInstallationId, ExternalConversationRef, ProductAdapterError, ProductAdapterId, + ProductOutboundEnvelope, ProductOutboundPayload, ProductOutboundTarget, + ProductSurfaceRejectionKind, ProjectionCursor, ProjectionStream, ProjectionStreamSubscription, + ProjectionSubscriptionRequest, ProtocolAuthFailure, RedactedString, +}; use ironclaw_product::{ AdminCreateUserFields, AdminCreatedUser, AdminUserError, AdminUserRecord, AdminUserRole, AdminUserSecretMeta, AdminUserService, AdminUserStatus, RebornAdminCreateUserRequest, @@ -137,10 +143,6 @@ use ironclaw_product::{ RebornAdminUserListResponse, RebornAdminUserRequest, RebornAdminUserResponse, RebornAdminUserSecretsListResponse, }; -use ironclaw_product::{ - ProductAdapterError, ProductOutboundEnvelope, ProductSurfaceRejectionKind, ProjectionCursor, - ProjectionStream, ProjectionSubscriptionRequest, ProtocolAuthFailure, RedactedString, -}; use ironclaw_threads::{ AcceptInboundMessageRequest, AcceptedInboundMessage, AcceptedInboundMessageReplay, AppendAssistantDraftRequest, AppendCapabilityDisplayPreviewRequest, @@ -167,7 +169,7 @@ use ironclaw_turns::{ use secrecy::SecretString; use serde::Serialize; use serde_json::json; -use tokio::sync::{Notify, oneshot}; +use tokio::sync::{Notify, mpsc, oneshot}; fn caller() -> ProductSurfaceCaller { caller_for_user("user-alpha") @@ -1715,6 +1717,119 @@ impl ProjectionStream for RecordingProjectionStream { } } +struct SubscribingProjectionStream { + event: ProductOutboundEnvelope, + subscriptions: Mutex>, + drain_count: Mutex, +} + +impl SubscribingProjectionStream { + fn new(event: ProductOutboundEnvelope) -> Self { + Self { + event, + subscriptions: Mutex::new(Vec::new()), + drain_count: Mutex::new(0), + } + } + + fn subscription_count(&self) -> usize { + self.subscriptions.lock().expect("lock").len() + } + + fn drain_count(&self) -> usize { + *self.drain_count.lock().expect("lock") + } +} + +#[async_trait] +impl ProjectionStream for SubscribingProjectionStream { + async fn drain( + &self, + _request: ProjectionSubscriptionRequest, + ) -> Result, ProductAdapterError> { + *self.drain_count.lock().expect("lock") += 1; + Ok(Vec::new()) + } + + fn supports_subscription(&self) -> bool { + true + } + + async fn subscribe( + &self, + request: ProjectionSubscriptionRequest, + ) -> Result { + self.subscriptions.lock().expect("lock").push(request); + let (sender, receiver) = mpsc::channel(1); + sender + .send(Ok(self.event.clone())) + .await + .expect("test subscription receiver remains open"); + Ok(ProjectionStreamSubscription::new(receiver)) + } +} + +struct ControlledSubscribingProjectionStream { + receiver: Mutex>>>, +} + +impl ControlledSubscribingProjectionStream { + fn new() -> ( + Self, + mpsc::Sender>, + ) { + let (sender, receiver) = mpsc::channel(2); + ( + Self { + receiver: Mutex::new(Some(receiver)), + }, + sender, + ) + } +} + +#[async_trait] +impl ProjectionStream for ControlledSubscribingProjectionStream { + async fn drain( + &self, + _request: ProjectionSubscriptionRequest, + ) -> Result, ProductAdapterError> { + Ok(Vec::new()) + } + + fn supports_subscription(&self) -> bool { + true + } + + async fn subscribe( + &self, + _request: ProjectionSubscriptionRequest, + ) -> Result { + let receiver = self + .receiver + .lock() + .expect("lock") + .take() + .expect("controlled stream subscribes once"); + Ok(ProjectionStreamSubscription::new(receiver)) + } +} + +fn keep_alive_outbound(cursor: &str) -> ProductOutboundEnvelope { + ProductOutboundEnvelope::new( + ProductAdapterId::new("webui_v2").expect("adapter id"), + AdapterInstallationId::new("install:alpha").expect("installation id"), + ProductOutboundTarget::new( + ReplyTargetBindingRef::new("reply:test").expect("reply target"), + ExternalConversationRef::new(None, "conversation:test", None, None) + .expect("conversation ref"), + None, + ), + ProjectionCursor::new(cursor).expect("projection cursor"), + ProductOutboundPayload::KeepAlive, + ) +} + /// Lighter-weight projection stream used by the timeline drain /// regressions: counts calls without retaining the request shape. Kept /// alongside `RecordingProjectionStream` because some sites only need @@ -3656,6 +3771,156 @@ async fn m2_service_stream_contract_uses_fake_projection_port_with_authenticated ); } +#[tokio::test] +async fn product_surface_subscription_stays_open_instead_of_polling_drain() { + let web_caller = caller(); + let expected = keep_alive_outbound("cursor-live"); + let event_stream = Arc::new(SubscribingProjectionStream::new(expected.clone())); + let services = RebornServices::new( + Arc::new(InMemorySessionThreadService::default()), + Arc::new(FakeTurnCoordinator::default()), + ) + .with_event_stream(event_stream.clone()); + create_thread_for(&services, web_caller.clone(), "thread-alpha").await; + + let mut response = ProductSurface::stream_events( + &services, + web_caller.clone(), + ProductSurfaceStreamRequest { + stream_id: Some("thread-alpha".to_string()), + after_cursor: None, + }, + ) + .await + .expect("subscription opens"); + let subscription = response + .subscription + .take() + .expect("stream response carries its live continuation"); + + assert_eq!( + response.events, + vec![serde_json::to_value(expected).expect("outbound event encodes")] + ); + assert_eq!(event_stream.subscription_count(), 1); + assert_eq!( + event_stream.drain_count(), + 0, + "a supported live subscription must not fall back to an empty poll" + ); + drop(subscription); + let drained = services + .stream_events( + web_caller, + RebornStreamEventsRequest { + thread_id: "thread-alpha".to_string(), + after_cursor: None, + }, + ) + .await + .expect("one-shot compatibility path drains"); + + assert!(drained.events.is_empty()); + assert_eq!(event_stream.subscription_count(), 1); + assert_eq!( + event_stream.drain_count(), + 1, + "the compatibility method retains its one-shot drain contract" + ); +} + +#[tokio::test(start_paused = true)] +async fn product_surface_subscription_bounds_a_silent_first_event() { + let web_caller = caller(); + let (event_stream, _sender) = ControlledSubscribingProjectionStream::new(); + let services = Arc::new( + RebornServices::new( + Arc::new(InMemorySessionThreadService::default()), + Arc::new(FakeTurnCoordinator::default()), + ) + .with_event_stream(Arc::new(event_stream)), + ); + create_thread_for(&services, web_caller.clone(), "thread-alpha").await; + + let task = tokio::spawn(async move { + ProductSurface::stream_events( + services.as_ref(), + web_caller, + ProductSurfaceStreamRequest { + stream_id: Some("thread-alpha".to_string()), + after_cursor: None, + }, + ) + .await + }); + tokio::task::yield_now().await; + tokio::time::advance(Duration::from_secs(1)).await; + tokio::task::yield_now().await; + + assert!( + task.is_finished(), + "a silent subscription must not suspend one-shot callers indefinitely" + ); + let response = task + .await + .expect("stream task joins") + .expect("silent subscription returns an empty response"); + assert!(response.events.is_empty()); + assert!(response.subscription.is_none()); +} + +#[tokio::test(start_paused = true)] +async fn product_surface_subscription_revalidates_visibility_without_blocking_events() { + let caller = caller(); + let thread_service = Arc::new(InMemorySessionThreadService::default()); + let trigger_thread_id = setup_trigger_thread( + &thread_service, + &caller, + "thread-trigger-subscription-revalidation", + ) + .await; + let automation_service = Arc::new(RevocableAutomationService::new( + trigger_thread_id.clone(), + &caller, + )); + let (event_stream, sender) = ControlledSubscribingProjectionStream::new(); + let services = RebornServices::new(thread_service, Arc::new(FakeTurnCoordinator::default())) + .with_automation_product_service(automation_service.clone()) + .with_event_stream(Arc::new(event_stream)); + + sender + .send(Ok(keep_alive_outbound("cursor-before-revocation"))) + .await + .expect("subscription receiver remains open"); + let mut response = ProductSurface::stream_events( + &services, + caller, + ProductSurfaceStreamRequest { + stream_id: Some(trigger_thread_id.to_string()), + after_cursor: None, + }, + ) + .await + .expect("visible automation thread subscribes"); + assert_eq!(response.events.len(), 1, "first event is authorized"); + let subscription = response + .subscription + .take() + .expect("stream response carries its live continuation"); + + tokio::task::yield_now().await; + automation_service.revoke(); + tokio::time::advance(Duration::from_secs(1)).await; + tokio::task::yield_now().await; + let error = tokio::time::timeout(Duration::from_secs(5), subscription.next()) + .await + .expect("revalidation must terminate the subscription") + .expect("revocation is surfaced") + .expect_err("revoked visibility must stop the subscription"); + + assert_eq!(error.code, ProductSurfaceErrorCode::NotFound); +} + #[tokio::test] async fn duplicate_submit_replays_prior_handoff_without_second_submission() { let threads: Arc = Arc::new(InMemorySessionThreadService::default()); diff --git a/crates/ironclaw_reborn_composition/CLAUDE.md b/crates/ironclaw_reborn_composition/CLAUDE.md index f6d4b3c3c1..afba94daac 100644 --- a/crates/ironclaw_reborn_composition/CLAUDE.md +++ b/crates/ironclaw_reborn_composition/CLAUDE.md @@ -396,12 +396,13 @@ runtime state stores sit behind the composed standalone root filesystem files). Production durable retention/live fanout still belongs in the host runtime/event-store follow-up rather than WebUI ingress. -Live cumulative assistant-text projections are producer-coalesced: the first -update is published immediately, rapid replacements publish the latest value -at most once per 75 ms, and any non-text milestone flushes the pending value -before that milestone is projected. Reasoning, capability, and lifecycle -milestones remain ordered and uncoalesced. The in-memory source still records -the latest projection for replay when no browser subscriber is active. +Live cumulative assistant-text projections are published at provider cadence. +The live-text publisher conflates only sub-frame microbursts within a 16 ms +window before they reach the event-stream manager's bounded subscriber. +Ordinary provider cadence remains incremental, while each microburst retains +its latest cumulative snapshot. Reasoning, capability, and lifecycle milestones +remain ordered. The in-memory source still records the latest projection for +replay when no browser subscriber is active. The opaque WebUI projection cursor stamps its process-local live position with the projection-services epoch. On an epoch mismatch, resume preserves durable @@ -439,13 +440,11 @@ axum::serve(listener, app).with_graceful_shutdown(shutdown).await?; — regression guard that the product projection adapter uses the service request actor when selecting the runtime event stream, rather than a hidden runtime owner actor. -- `src/projection/tests/live_progress_stream.rs::live_assistant_text_coalescer_flushes_latest_update_on_timer` - — regression guard that rapid cumulative text still advances while the - model continues streaming. -- `src/projection/tests/live_progress_stream.rs::live_assistant_text_burst_stays_subscribed_and_flushes_before_tool_activity` - — caller-level regression guard that a provider-rate text burst does not - terminate the bounded WebUI subscription and that the latest text is - published before the next capability milestone. +- `src/projection/tests/live_progress_stream.rs::provider_rate_live_text_stays_smooth_and_precedes_tool_activity` + — caller-level regression guard that provider-rate cumulative text remains + smooth without terminating the bounded WebUI subscription, provider-cadence + snapshots precede the next capability milestone, and a sub-frame microburst + retains its latest cumulative snapshot before that milestone. - `tests/webui_v2_serve.rs` — caller-level tests driving the composed `Router` through `tower::ServiceExt::oneshot`: bearer happy path, missing/invalid bearer 401, SSE `?token=`, timeline rejects `?token=`, diff --git a/crates/ironclaw_reborn_composition/src/runtime/tests/core.rs b/crates/ironclaw_reborn_composition/src/runtime/tests/core.rs index 43d7999ba7..a370cc6fd1 100644 --- a/crates/ironclaw_reborn_composition/src/runtime/tests/core.rs +++ b/crates/ironclaw_reborn_composition/src/runtime/tests/core.rs @@ -5069,13 +5069,14 @@ async fn standalone_runtime_webui_bundle_reuses_thread_and_turn_services() { panic!("webui submit should start a run"); }; let stream = tokio::time::timeout(Duration::from_secs(3), async { + let mut after_cursor = None; loop { let stream = stream_product_events( bundle.as_ref(), caller.clone(), RebornStreamEventsRequest { thread_id: created.thread.thread_id.to_string(), - after_cursor: None, + after_cursor: after_cursor.clone(), }, ) .await @@ -5098,6 +5099,10 @@ async fn standalone_runtime_webui_bundle_reuses_thread_and_turn_services() { }) { break stream; } + after_cursor = stream + .events + .last() + .map(|event| event.projection_cursor().clone()); tokio::time::sleep(Duration::from_millis(10)).await; } }) diff --git a/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs b/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs index b861fc9c2a..b23694eb10 100644 --- a/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs +++ b/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs @@ -1019,6 +1019,7 @@ impl ironclaw_host_api::ProductSurface for StubServices { Ok(ironclaw_host_api::ProductSurfaceStreamResponse { events: Vec::new(), next_cursor: None, + subscription: None, }) } } diff --git a/crates/ironclaw_reborn_openai_compat/tests/support/mod.rs b/crates/ironclaw_reborn_openai_compat/tests/support/mod.rs index e43ee5e9f4..ebfb601e41 100644 --- a/crates/ironclaw_reborn_openai_compat/tests/support/mod.rs +++ b/crates/ironclaw_reborn_openai_compat/tests/support/mod.rs @@ -381,6 +381,7 @@ impl ProductSurface for FakeProductSurface { Ok(ironclaw_host_api::ProductSurfaceStreamResponse { events, next_cursor: None, + subscription: None, }) } } diff --git a/crates/ironclaw_webui/CLAUDE.md b/crates/ironclaw_webui/CLAUDE.md index b5f7ea38f3..e20623f312 100644 --- a/crates/ironclaw_webui/CLAUDE.md +++ b/crates/ironclaw_webui/CLAUDE.md @@ -140,27 +140,52 @@ route (tenant/user-scoped tool-approval settings), not an operator route. (default 3 concurrent; override via `WebUiV2State::with_sse_concurrency_limit`) — a caller cannot bypass the cap by mixing SSE and WS. Exhaustion returns `429` with `retryable: true`. -- The SPA also sends a bounded, random `connection_id` that is stable for one - loaded browser tab plus a monotonically increasing `connection_generation` - for each EventSource it creates. A same-caller, same-id stream supersedes its - prior generation without consuming another slot; a delayed older generation - receives `204` and cannot cancel the current stream. This prevents - proxy-reordered closes/opens during thread navigation from stranding the - replacement stream behind the cap; distinct tabs still consume distinct - slots. +- The SPA consumes SSE through `event-source-plus`, which owns event framing, + `Last-Event-ID`, abort, and retry/backoff over `fetch`/`ReadableStream`. The + bearer is sent in the `Authorization` header rather than the request URL. A + bounded, random `connection_id` remains stable for one loaded browser tab and + `connection_generation` increments for every package-managed request. A + same-caller, same-id stream supersedes its prior generation without consuming + another slot; a delayed older generation receives `204` and cannot cancel the + current stream. This prevents proxy-reordered closes/opens during thread + navigation from stranding the replacement stream behind the cap; distinct + tabs still consume distinct slots. - A successful facade subscription emits an application-level `keep_alive` - frame immediately after admission. Browser connection state uses that frame - as proof that the projection tail is ready instead of waiting for a model - delta or the periodic transport keep-alive. -- `after_cursor` is retained only within one mounted Chat route (including - native EventSource retries and visibility recovery). A route/thread remount + frame immediately after admission and every 15 seconds while the projection + is idle. Browser connection state and its activity watchdog use those frames + as liveness proof; Axum's comment-only transport keep-alive still protects + proxies, but SSE parser packages do not expose comments to application code. +- Subscription-capable product surfaces keep one projection subscription alive + for the entire SSE connection. Do not rebuild a one-event subscription after + each frame: model/tool milestones emitted between teardown and resubscribe + are not guaranteed to remain in the compacted live state. The product bridge + revalidates thread visibility on an independent bounded cadence so storage + I/O never gates individual text frames; drain/poll remains only for + compatibility surfaces without subscriptions. +- Live assistant text is cumulative within one model call and keyed by both + turn run and model-call phase. A later model call therefore starts a new + assistant item instead of replacing an earlier utterance from the same run. + The SPA marks the prior phase as no longer streaming, retains it as + intermediate text, and upgrades only the latest phase when the durable final + reply arrives. These phase items remain live-projection/session state rather + than durable transcript records. +- Active assistant phases render accumulated Markdown through Streamdown's + incomplete-Markdown-aware streaming mode. The product projection boundary + publishes cumulative text at most once per 16 ms browser-paint interval, + keeping only the latest replaceable snapshot inside that interval. Do not + raise the projection subscription buffer or restore the old 75 ms window: + the former only postpones lag under provider microbursts, while the latter + makes ordinary text visibly chunky. Completed phases continue through the + existing marked + DOMPurify renderer and code-block enhancement path. +- The packaged SSE client retains `Last-Event-ID` only within one mounted Chat + route, including retries and visibility recovery. A route/thread remount starts at the projection origin so the server returns durable state plus the compacted current live state; it does not persist process-local live cursors across SPA navigation. - Every stream is closed after a max lifetime (5 min) and every `socket.send` / - drain await is `timeout`-bounded, so a back-pressuring client or a stalled - facade cannot pin a slot past the budget. Slots are RAII (`SseSlot`), released - on disconnect / expiry / error. Regressions locked by + subscription/drain await is `timeout`-bounded, so a back-pressuring client or + a stalled facade cannot pin a slot past the budget. Slots are RAII + (`SseSlot`), released on disconnect / expiry / error. Regressions locked by `stream_events_ws_shares_capacity_with_sse_streams` and `stream_events_releases_slot_when_facade_drain_stalls_past_max_lifetime`. - `capability_activity` / `capability_display_preview` frames carry only diff --git a/crates/ironclaw_webui/frontend/package.json b/crates/ironclaw_webui/frontend/package.json index acde10f351..37d96223d7 100644 --- a/crates/ironclaw_webui/frontend/package.json +++ b/crates/ironclaw_webui/frontend/package.json @@ -23,6 +23,7 @@ "dependencies": { "@tanstack/react-query": "5.87.4", "dompurify": "3.2.3", + "event-source-plus": "0.1.15", "highlight.js": "11.11.1", "marked": "17.0.2", "qrcode": "1.5.4", @@ -30,7 +31,8 @@ "react-dom": "19.1.0", "react-hook-form": "7.62.0", "react-hot-toast": "2.6.0", - "react-router": "7.15.1" + "react-router": "7.15.1", + "streamdown": "2.5.0" }, "devDependencies": { "@tailwindcss/vite": "4.3.2", diff --git a/crates/ironclaw_webui/frontend/pnpm-lock.yaml b/crates/ironclaw_webui/frontend/pnpm-lock.yaml index 6a8be2abae..4931bc2d64 100644 --- a/crates/ironclaw_webui/frontend/pnpm-lock.yaml +++ b/crates/ironclaw_webui/frontend/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: dompurify: specifier: 3.2.3 version: 3.2.3 + event-source-plus: + specifier: 0.1.15 + version: 0.1.15 highlight.js: specifier: 11.11.1 version: 11.11.1 @@ -38,6 +41,9 @@ importers: react-router: specifier: 7.15.1 version: 7.15.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + streamdown: + specifier: 2.5.0 + version: 2.5.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) devDependencies: '@tailwindcss/vite': specifier: 4.3.2 @@ -75,6 +81,15 @@ importers: packages: + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} @@ -84,6 +99,12 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.4': + resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -100,6 +121,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mermaid-js/parser@1.2.0': + resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==} + '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: @@ -318,12 +342,123 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.4': + resolution: {integrity: sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@26.1.0': resolution: {integrity: sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==} @@ -341,12 +476,24 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/whatwg-mimetype@3.0.2': resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@ungap/structured-clone@1.3.3': + resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} + + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + '@vitejs/plugin-react@6.0.3': resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -401,6 +548,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + buffer-image-size@0.6.4: resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} engines: {node: '>=4.0'} @@ -409,13 +559,32 @@ packages: resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} engines: {node: '>=6'} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + cliui@6.0.0: resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -423,6 +592,17 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -430,23 +610,216 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.34.0: + resolution: {integrity: sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + decamelize@1.2.0: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dijkstrajs@1.0.3: resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} dompurify@3.2.3: resolution: {integrity: sha512-U1U5Hzc2MO0oW3DF+G9qYN0aT7atAou4AgI0XjWz061nyBPbdxkfdhfy5uMgGn6+oLFCfn44ZGbdDqCzVmlOWA==} + dompurify@3.4.12: + resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -454,6 +827,10 @@ packages: resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} engines: {node: '>=10.13.0'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} @@ -461,13 +838,29 @@ packages: es-module-lexer@2.3.0: resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==} + es-toolkit@1.50.0: + resolution: {integrity: sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + event-source-plus@0.1.15: + resolution: {integrity: sha512-kt3z/UwDbZxHttynwmXlqTf1qknWqPgswsbvSok1ob6SveMts4BqRXow6aiwB55xTY1XvSXuhn+IvYQErWLyKA==} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -498,22 +891,101 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + happy-dom@20.11.1: resolution: {integrity: sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==} engines: {node: '>=20.0.0'} + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + + hast-util-sanitize@5.0.2: + resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==} + + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-to-parse5@8.0.1: + resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + highlight.js@11.11.1: resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} + html-url-attributes@3.0.1: + resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + katex@0.16.47: + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + hasBin: true + + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -592,23 +1064,178 @@ packages: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + marked@17.0.2: resolution: {integrity: sha512-s5HZGFQea7Huv5zZcAGhJLT3qLpAfnY7v7GWkICUr0+Wd5TFEtdlRR2XUL5Gg+RH7u2Df595ifrxR03mBaw7gA==} engines: {node: '>= 20'} hasBin: true + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + mermaid@11.16.0: + resolution: {integrity: sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==} + + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.15: resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + obug@2.1.3: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} @@ -621,6 +1248,18 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + package-manager-detector@1.8.0: + resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} + + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -639,10 +1278,19 @@ packages: resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} engines: {node: '>=10.13.0'} + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + postcss@8.5.16: resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} engines: {node: ^10 || ^12 || >=14} + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + qrcode@1.5.4: resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} engines: {node: '>=10.13.0'} @@ -680,6 +1328,30 @@ packages: resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} engines: {node: '>=0.10.0'} + rehype-harden@1.1.8: + resolution: {integrity: sha512-Qn7vR1xrf6fZCrkm9TDWi/AB4ylrHy+jqsNm1EHOAmbARYA6gsnVJBq/sdBh6kmT4NEZxH5vgIjrscefJAOXcw==} + + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + + rehype-sanitize@6.0.0: + resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} + + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + remend@1.3.0: + resolution: {integrity: sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -687,11 +1359,23 @@ packages: require-main-filename@2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + rolldown@1.1.4: resolution: {integrity: sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + scheduler@0.26.0: resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} @@ -708,20 +1392,44 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + streamdown@2.5.0: + resolution: {integrity: sha512-/tTnURfIOxZK/pqJAxsfCvETG/XCJHoWnk3jq9xLcuz6CSpnjjuxSRBTTL4PKGhxiZQf0lqPxGhImdpwcZ2XwA==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + + tailwind-merge@3.6.0: + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} + tailwindcss@4.3.2: resolution: {integrity: sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==} @@ -744,6 +1452,16 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + ts-dedent@2.3.0: + resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==} + engines: {node: '>=6.10'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -752,9 +1470,43 @@ packages: engines: {node: '>=14.17'} hasBin: true + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + uuid@14.0.1: + resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} + hasBin: true + + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite@8.1.3: resolution: {integrity: sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -839,6 +1591,9 @@ packages: jsdom: optional: true + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'} @@ -878,8 +1633,20 @@ packages: resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} engines: {node: '>=8'} + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.8.0 + tinyexec: 1.2.4 + + '@braintree/sanitize-url@7.1.2': {} + + '@chevrotain/types@11.1.2': {} + '@emnapi/core@1.11.1': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -896,6 +1663,14 @@ snapshots: tslib: 2.8.1 optional: true + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.4': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + import-meta-resolve: 4.2.0 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -915,6 +1690,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mermaid-js/parser@1.2.0': + dependencies: + '@chevrotain/types': 11.1.2 + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 @@ -1062,10 +1841,147 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.4': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.9 + '@types/estree@1.0.9': {} + '@types/geojson@7946.0.16': {} + + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/ms@2.1.0': {} + '@types/node@26.1.0': dependencies: undici-types: 8.3.0 @@ -1085,12 +2001,23 @@ snapshots: '@types/trusted-types@2.0.7': optional: true + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + '@types/whatwg-mimetype@3.0.2': {} '@types/ws@8.18.1': dependencies: '@types/node': 26.1.0 + '@ungap/structured-clone@1.3.3': {} + + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + '@vitejs/plugin-react@6.0.3(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0))': dependencies: '@rolldown/pluginutils': 1.0.1 @@ -1145,42 +2072,280 @@ snapshots: assertion-error@2.0.1: {} + bail@2.0.2: {} + buffer-image-size@0.6.4: dependencies: '@types/node': 26.1.0 camelcase@5.3.1: {} + ccount@2.0.1: {} + chai@6.2.2: {} + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + cliui@6.0.0: dependencies: string-width: 4.2.3 strip-ansi: 6.0.1 wrap-ansi: 6.2.0 + clsx@2.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 color-name@1.1.4: {} + comma-separated-tokens@2.0.3: {} + + commander@7.2.0: {} + + commander@8.3.0: {} + convert-source-map@2.0.0: {} cookie@1.1.1: {} + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + csstype@3.2.3: {} + cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.0): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.34.0 + + cytoscape-fcose@2.2.0(cytoscape@3.34.0): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.34.0 + + cytoscape@3.34.0: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.14: + dependencies: + d3: 7.9.0 + lodash-es: 4.18.1 + + dayjs@1.11.21: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + decamelize@1.2.0: {} + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + + dequal@2.0.3: {} + + destr@2.0.5: {} + detect-libc@2.1.2: {} + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + dijkstrajs@1.0.3: {} dompurify@3.2.3: optionalDependencies: '@types/trusted-types': 2.0.7 + dompurify@3.4.12: + optionalDependencies: + '@types/trusted-types': 2.0.7 + emoji-regex@8.0.0: {} enhanced-resolve@5.21.6: @@ -1188,16 +2353,30 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + entities@6.0.1: {} + entities@7.0.1: {} es-module-lexer@2.3.0: {} + es-toolkit@1.50.0: {} + + escape-string-regexp@5.0.0: {} + + estree-util-is-identifier-name@3.0.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 + event-source-plus@0.1.15: + dependencies: + ofetch: 1.5.1 + expect-type@1.4.0: {} + extend@3.0.2: {} + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 @@ -1218,6 +2397,8 @@ snapshots: graceful-fs@4.2.11: {} + hachure-fill@0.5.2: {} + happy-dom@20.11.1: dependencies: '@types/node': 26.1.0 @@ -1231,12 +2412,130 @@ snapshots: - bufferutil - utf-8-validate + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.2.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-raw@9.1.0: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.3.3 + hast-util-from-parse5: 8.0.3 + hast-util-to-parse5: 8.0.1 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + parse5: 7.3.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-sanitize@5.0.2: + dependencies: + '@types/hast': 3.0.5 + '@ungap/structured-clone': 1.3.3 + unist-util-position: 5.0.0 + + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-to-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.5 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.5 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + highlight.js@11.11.1: {} + html-url-attributes@3.0.1: {} + + html-void-elements@3.0.0: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + import-meta-resolve@4.2.0: {} + + inline-style-parser@0.2.7: {} + + internmap@1.0.1: {} + + internmap@2.0.3: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-decimal@2.0.1: {} + is-fullwidth-code-point@3.0.0: {} + is-hexadecimal@2.0.1: {} + + is-plain-obj@4.1.0: {} + jiti@2.7.0: {} + katex@0.16.47: + dependencies: + commander: 8.3.0 + + khroma@2.1.0: {} + + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + lightningcss-android-arm64@1.32.0: optional: true @@ -1290,16 +2589,402 @@ snapshots: dependencies: p-locate: 4.1.0 + lodash-es@4.18.1: {} + + longest-streak@3.1.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + markdown-table@3.0.4: {} + + marked@16.4.2: {} + marked@17.0.2: {} + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.3 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + mermaid@11.16.0: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.4 + '@mermaid-js/parser': 1.2.0 + '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 + cytoscape: 3.34.0 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.0) + cytoscape-fcose: 2.2.0(cytoscape@3.34.0) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.14 + dayjs: 1.11.21 + dompurify: 3.4.12 + es-toolkit: 1.50.0 + katex: 0.16.47 + khroma: 2.1.0 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.4.0 + ts-dedent: 2.3.0 + uuid: 14.0.1 + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + + ms@2.1.3: {} + nanoid@3.3.15: {} + node-fetch-native@1.6.7: {} + obug@2.1.3: {} + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.4 + p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -1310,6 +2995,24 @@ snapshots: p-try@2.2.0: {} + package-manager-detector@1.8.0: {} + + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + path-data-parser@0.1.0: {} + path-exists@4.0.0: {} pathe@2.0.3: {} @@ -1320,12 +3023,21 @@ snapshots: pngjs@5.0.0: {} + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + postcss@8.5.16: dependencies: nanoid: 3.3.15 picocolors: 1.1.1 source-map-js: 1.2.1 + property-information@7.2.0: {} + qrcode@1.5.4: dependencies: dijkstrajs: 1.0.3 @@ -1358,10 +3070,63 @@ snapshots: react@19.1.0: {} + rehype-harden@1.1.8: + dependencies: + unist-util-visit: 5.1.0 + + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + + rehype-sanitize@6.0.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-sanitize: 5.0.2 + + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + + remend@1.3.0: {} + require-directory@2.1.1: {} require-main-filename@2.0.0: {} + robust-predicates@3.0.3: {} + rolldown@1.1.4: dependencies: '@oxc-project/types': 0.138.0 @@ -1383,6 +3148,17 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.1.4 '@rolldown/binding-win32-x64-msvc': 1.1.4 + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + + rw@1.3.3: {} + + safer-buffer@2.1.2: {} + scheduler@0.26.0: {} set-blocking@2.0.0: {} @@ -1393,20 +3169,62 @@ snapshots: source-map-js@1.2.1: {} + space-separated-tokens@2.0.2: {} + stackback@0.0.2: {} std-env@4.1.0: {} + streamdown@2.5.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + clsx: 2.1.1 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + marked: 17.0.2 + mermaid: 11.16.0 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + rehype-harden: 1.1.8 + rehype-raw: 7.0.0 + rehype-sanitize: 6.0.0 + remark-gfm: 4.0.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + remend: 1.3.0 + tailwind-merge: 3.6.0 + unified: 11.0.5 + unist-util-visit: 5.1.0 + unist-util-visit-parents: 6.0.2 + transitivePeerDependencies: + - supports-color + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + + stylis@4.4.0: {} + + tailwind-merge@3.6.0: {} + tailwindcss@4.3.2: {} tapable@2.3.3: {} @@ -1422,13 +3240,71 @@ snapshots: tinyrainbow@3.1.0: {} + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + ts-dedent@2.3.0: {} + tslib@2.8.1: optional: true typescript@6.0.3: {} + ufo@1.6.4: {} + undici-types@8.3.0: {} + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + uuid@14.0.1: {} + + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0): dependencies: lightningcss: 1.32.0 @@ -1469,6 +3345,8 @@ snapshots: transitivePeerDependencies: - msw + web-namespaces@2.0.1: {} + whatwg-mimetype@3.0.0: {} which-module@2.0.1: {} @@ -1506,3 +3384,5 @@ snapshots: which-module: 2.0.1 y18n: 4.0.3 yargs-parser: 18.1.3 + + zwitch@2.0.4: {} diff --git a/crates/ironclaw_webui/frontend/src/lib/api.test.ts b/crates/ironclaw_webui/frontend/src/lib/api.test.ts index 8e247b43d9..113a29f060 100644 --- a/crates/ironclaw_webui/frontend/src/lib/api.test.ts +++ b/crates/ironclaw_webui/frontend/src/lib/api.test.ts @@ -7,6 +7,7 @@ import { clientActionId, deleteAutomation, deleteThread, + eventStreamRequest, fetchAttachmentBlob, fetchAttachmentDataUrl, listAutomations, @@ -79,6 +80,45 @@ test("listAutomations reads through the v2 automations route", async () => { assert.equal(calls[0].options.headers.get("Authorization"), "Bearer token-1"); }); +test("eventStreamRequest keeps the bearer out of the stream URL", () => { + const sessionStorageDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + "sessionStorage", + ); + const windowDescriptor = Object.getOwnPropertyDescriptor(globalThis, "window"); + try { + globalThis.sessionStorage = { + getItem: () => "token-1", + setItem: () => {}, + removeItem: () => {}, + }; + globalThis.window = { location: { origin: "http://localhost" } }; + + const request = eventStreamRequest({ + threadId: "thread/needs encoding", + connectionId: "connection-1", + }); + + assert.equal( + request.url, + "http://localhost/api/webchat/v2/threads/thread%2Fneeds%20encoding/events?connection_id=connection-1", + ); + assert.equal(new URL(request.url).searchParams.has("token"), false); + assert.deepEqual(request.headers(), { Authorization: "Bearer token-1" }); + } finally { + restoreGlobal("sessionStorage", sessionStorageDescriptor); + restoreGlobal("window", windowDescriptor); + } +}); + +function restoreGlobal(name, descriptor) { + if (descriptor) { + Object.defineProperty(globalThis, name, descriptor); + } else { + delete globalThis[name]; + } +} + test("listAutomations propagates api errors from the automations route", async () => { globalThis.sessionStorage = { getItem: () => "", diff --git a/crates/ironclaw_webui/frontend/src/lib/api.ts b/crates/ironclaw_webui/frontend/src/lib/api.ts index 4ff5864547..3e5e848d55 100644 --- a/crates/ironclaw_webui/frontend/src/lib/api.ts +++ b/crates/ironclaw_webui/frontend/src/lib/api.ts @@ -576,27 +576,21 @@ export async function fetchAttachmentDataUrl(path) { // --- Streaming (SSE) --- -// `EventSource` cannot set request headers, so the token rides as a -// query param. The composition middleware accepts `?token=` for this -// route specifically (in-scope "SSE query-token exception" from #3886). -export function openEventStream({ - threadId, - afterCursor, - connectionId, - connectionGeneration, -} = {}) { +export function eventStreamRequest({ threadId, connectionId } = {}) { const url = new URL( `${V2_BASE}/threads/${encodeURIComponent(threadId)}/events`, window.location.origin, ); - const token = readStoredToken(); - if (token) url.searchParams.set("token", token); - if (afterCursor) url.searchParams.set("after_cursor", afterCursor); if (connectionId) url.searchParams.set("connection_id", connectionId); - if (connectionGeneration != null) { - url.searchParams.set("connection_generation", String(connectionGeneration)); - } - return new EventSource(url.toString()); + return { + url: url.toString(), + // A function gives every reconnect the latest credential without putting + // the bearer in browser history, proxy logs, or the request URL. + headers: () => { + const token = readStoredToken(); + return token ? { Authorization: `Bearer ${token}` } : {}; + }, + }; } // --- Streaming (WebSocket) --- diff --git a/crates/ironclaw_webui/frontend/src/pages/chat/components/activity-run.test.ts b/crates/ironclaw_webui/frontend/src/pages/chat/components/activity-run.test.ts index db0be036c5..7bb1ee7226 100644 --- a/crates/ironclaw_webui/frontend/src/pages/chat/components/activity-run.test.ts +++ b/crates/ironclaw_webui/frontend/src/pages/chat/components/activity-run.test.ts @@ -29,7 +29,6 @@ test("ActivityRun keeps running tool activity collapsed by default", () => { Icon() {}, MarkdownRenderer() {}, React: { - useEffect: () => {}, useMemo: (factory) => factory(), useState: (initial) => [typeof initial === "function" ? initial() : initial, () => {}], }, @@ -57,14 +56,13 @@ test("ActivityRun keeps running tool activity collapsed by default", () => { assert.equal(hasComponentNamed(tree, "ActivityItem"), false); }); -test("ActivityRun auto-expands declined tool activity", () => { +test("ActivityRun keeps declined tool activity collapsed", () => { const context = { globalThis: {}, html: (strings, ...values) => ({ strings: Array.from(strings), values }), Icon() {}, MarkdownRenderer() {}, React: { - useEffect: () => {}, useMemo: (factory) => factory(), useState: (initial) => [typeof initial === "function" ? initial() : initial, () => {}], }, @@ -89,8 +87,80 @@ test("ActivityRun auto-expands declined tool activity", () => { ], }); - assert.ok(containsScalar(tree, "true")); - assert.ok(hasComponentNamed(tree, "ActivityItem")); + assert.ok(containsScalar(tree, "false")); + assert.equal(hasComponentNamed(tree, "ActivityItem"), false); +}); + +test("ActivityRun keeps failed nested tool activity collapsed", () => { + const context = { + globalThis: {}, + html: (strings, ...values) => ({ strings: Array.from(strings), values }), + Icon() {}, + MarkdownRenderer() {}, + React: { + useMemo: (factory) => factory(), + useState: (initial) => [typeof initial === "function" ? initial() : initial, () => {}], + }, + summarizeActivity: () => ({ + label: "Activity - 1 tool, 1 failed", + hasError: true, + }), + useT: () => (key) => key, + ToolActivity() {}, + }; + + vm.runInNewContext(activityRunSourceForTest(), context); + const tree = context.globalThis.__testExports.ActivityRun({ + activity: [ + { + id: "assistant-tool-call", + role: "assistant", + toolCalls: [ + { + id: "tool-search", + toolName: "web-access.search", + toolStatus: "error", + }, + ], + }, + ], + }); + + assert.ok(containsScalar(tree, "false")); + assert.equal(hasComponentNamed(tree, "ActivityItem"), false); +}); + +test("ActivityRun keeps reasoning activity collapsed", () => { + const context = { + globalThis: {}, + html: (strings, ...values) => ({ strings: Array.from(strings), values }), + Icon() {}, + MarkdownRenderer() {}, + React: { + useMemo: (factory) => factory(), + useState: (initial) => [typeof initial === "function" ? initial() : initial, () => {}], + }, + summarizeActivity: () => ({ + label: "Activity", + hasError: false, + }), + useT: () => (key) => key, + ToolActivity() {}, + }; + + vm.runInNewContext(activityRunSourceForTest(), context); + const tree = context.globalThis.__testExports.ActivityRun({ + activity: [ + { + id: "reasoning", + role: "thinking", + content: "Considering the available evidence.", + }, + ], + }); + + assert.ok(containsScalar(tree, "false")); + assert.equal(hasComponentNamed(tree, "ActivityItem"), false); }); function hasComponentNamed(node, name) { diff --git a/crates/ironclaw_webui/frontend/src/pages/chat/components/activity-run.tsx b/crates/ironclaw_webui/frontend/src/pages/chat/components/activity-run.tsx index 71ccb9250b..cd4f431e13 100644 --- a/crates/ironclaw_webui/frontend/src/pages/chat/components/activity-run.tsx +++ b/crates/ironclaw_webui/frontend/src/pages/chat/components/activity-run.tsx @@ -27,12 +27,7 @@ type ReasoningItemProps = { export function ActivityRun({ activity, activeRunId = null }: ActivityRunProps) { const t = useT(); const summary = React.useMemo(() => summarizeActivity(activity, t), [activity, t]); - const shouldAutoExpand = shouldExpandActivityRun(activity); - const [expanded, setExpanded] = React.useState(shouldAutoExpand); - - React.useEffect(() => { - if (shouldAutoExpand) setExpanded(true); - }, [shouldAutoExpand]); + const [expanded, setExpanded] = React.useState(false); return (
@@ -117,21 +112,3 @@ function ReasoningItem({ content, streaming = false }: ReasoningItemProps) { function hasToolCalls(item) { return item?.toolCalls && item.toolCalls.length > 0; } - -function shouldExpandActivityRun(activity) { - return (activity || []).some((item) => { - if (item?.role === "thinking") return true; - if ( - item?.toolStatus === "error" || - item?.toolStatus === "declined" - ) { - return true; - } - if (!hasToolCalls(item)) return false; - return item.toolCalls.some( - (tool) => - tool?.toolStatus === "error" || - tool?.toolStatus === "declined", - ); - }); -} diff --git a/crates/ironclaw_webui/frontend/src/pages/chat/components/markdown-renderer.streamdown-security.test.tsx b/crates/ironclaw_webui/frontend/src/pages/chat/components/markdown-renderer.streamdown-security.test.tsx new file mode 100644 index 0000000000..d80429d251 --- /dev/null +++ b/crates/ironclaw_webui/frontend/src/pages/chat/components/markdown-renderer.streamdown-security.test.tsx @@ -0,0 +1,49 @@ +// @vitest-environment happy-dom +// @ts-nocheck +import assert from "node:assert/strict"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { test, vi } from "vitest"; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock("../../../lib/i18n", () => ({ useT: () => (key) => key })); + +test("streaming Markdown rejects executable model-authored HTML and URLs", async () => { + const { MarkdownRenderer } = await import("./markdown-renderer"); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + try { + act(() => { + root.render(React.createElement(MarkdownRenderer, { + content: [ + '', + '[unsafe](javascript:globalThis.__streamdownXss=true)', + "", + "**safe text remains**", + ].join("\n\n"), + streaming: true, + })); + }); + await act(async () => { + await Promise.resolve(); + }); + + assert.equal(container.querySelector("script"), null); + assert.equal(container.querySelector("[onerror]"), null); + assert.equal( + Array.from(container.querySelectorAll("a")).some((link) => + link.getAttribute("href")?.toLowerCase().startsWith("javascript:") + ), + false, + ); + assert.match(container.textContent || "", /safe text remains/); + assert.equal(globalThis.__streamdownXss, undefined); + } finally { + act(() => root.unmount()); + container.remove(); + delete globalThis.__streamdownXss; + } +}); diff --git a/crates/ironclaw_webui/frontend/src/pages/chat/components/markdown-renderer.streaming.test.tsx b/crates/ironclaw_webui/frontend/src/pages/chat/components/markdown-renderer.streaming.test.tsx index f8d1df7a5e..a76d83a8ba 100644 --- a/crates/ironclaw_webui/frontend/src/pages/chat/components/markdown-renderer.streaming.test.tsx +++ b/crates/ironclaw_webui/frontend/src/pages/chat/components/markdown-renderer.streaming.test.tsx @@ -13,9 +13,23 @@ const { renderMarkdownMock } = vi.hoisted(() => ({ vi.mock("../../../lib/i18n", () => ({ useT: () => (key) => key })); vi.mock("../../../lib/markdown", () => ({ renderMarkdown: renderMarkdownMock })); +vi.mock("streamdown", async () => { + const { createElement } = await import("react"); + return { + Streamdown: ({ children, isAnimating, mode }) => + createElement( + "div", + { + "data-animating": String(Boolean(isAnimating)), + "data-mode": mode, + "data-testid": "streamdown", + }, + children, + ), + }; +}); -test("streaming Markdown throttles snapshots and stops after a load failure", async () => { - vi.useFakeTimers(); +test("streaming Markdown updates immediately and finalizes through the sanitizer", async () => { renderMarkdownMock.mockReset(); renderMarkdownMock.mockImplementation((content) => `

${content}

`); const { MarkdownRenderer } = await import("./markdown-renderer"); @@ -31,9 +45,22 @@ test("streaming Markdown throttles snapshots and stops after a load failure", as })); }); await act(async () => { - await vi.advanceTimersByTimeAsync(0); + await Promise.resolve(); }); - assert.equal(renderMarkdownMock.mock.calls.length, 1); + assert.equal(container.textContent, "**first**"); + assert.equal( + container.querySelector('[data-testid="streamdown"]')?.getAttribute( + "data-mode", + ), + "streaming", + ); + assert.equal( + container.querySelector('[data-testid="streamdown"]')?.getAttribute( + "data-animating", + ), + "true", + ); + assert.equal(renderMarkdownMock.mock.calls.length, 0); act(() => { root.render(React.createElement(MarkdownRenderer, { @@ -41,35 +68,35 @@ test("streaming Markdown throttles snapshots and stops after a load failure", as streaming: true, })); }); - await act(async () => { - await vi.advanceTimersByTimeAsync(149); - }); - assert.equal(renderMarkdownMock.mock.calls.length, 1); - await act(async () => { - await vi.advanceTimersByTimeAsync(1); - }); - assert.equal(renderMarkdownMock.mock.calls.length, 2); + assert.equal( + container.textContent, + "**second**", + "a committed stream snapshot should not wait for a fixed timer", + ); + assert.equal(renderMarkdownMock.mock.calls.length, 0); - renderMarkdownMock.mockImplementation(() => { - throw new Error("markdown chunk failed"); - }); act(() => { root.render(React.createElement(MarkdownRenderer, { - content: "**failure**", - streaming: true, + content: "**final**", + streaming: false, })); }); + assert.equal( + container.querySelector('[data-testid="streamdown"]')?.getAttribute( + "data-mode", + ), + "static", + "the already-mounted stream renderer should hold the final snapshot while sanitization loads", + ); + assert.equal(container.textContent, "**final**"); await act(async () => { - await vi.advanceTimersByTimeAsync(150); + await Promise.resolve(); }); - assert.equal(renderMarkdownMock.mock.calls.length, 3); - await act(async () => { - await vi.advanceTimersByTimeAsync(1_000); - }); - assert.equal(renderMarkdownMock.mock.calls.length, 3); + assert.equal(renderMarkdownMock.mock.calls.length, 1); + assert.equal(renderMarkdownMock.mock.calls[0][0], "**final**"); + assert.equal(container.innerHTML.includes("

**final**

"), true); } finally { act(() => root.unmount()); container.remove(); - vi.useRealTimers(); } }); diff --git a/crates/ironclaw_webui/frontend/src/pages/chat/components/markdown-renderer.test.ts b/crates/ironclaw_webui/frontend/src/pages/chat/components/markdown-renderer.test.ts index ad58875126..5a585a60bb 100644 --- a/crates/ironclaw_webui/frontend/src/pages/chat/components/markdown-renderer.test.ts +++ b/crates/ironclaw_webui/frontend/src/pages/chat/components/markdown-renderer.test.ts @@ -190,41 +190,51 @@ test("markdown and syntax highlighting stay out of the synchronous chat chunk", ); }); -test("streaming markdown renders sanitized snapshots at a bounded cadence", () => { +test("streaming markdown delegates accumulated text without a fixed render interval", () => { assert.match( rendererSource, - /const STREAMING_RENDER_INTERVAL_MS = 150/, - "streaming rendering should have an explicit cadence budget", + /import\("streamdown"\)/, + "streaming replies should use the streaming-optimized Markdown renderer", ); assert.match( rendererSource, - /const delay = Math\.max\(0, STREAMING_RENDER_INTERVAL_MS[\s\S]*setTimeout/, - "streaming updates should batch rendering instead of parsing every projection", + //, + "the lazy streaming renderer should retain an escaped-text fallback", ); assert.match( rendererSource, - /if \(streaming \|\| renderedHtml === null\) return undefined/, - "syntax highlighting and code controls should wait for the completed reply", + /if \(streaming \|\| keepStreamingRendererUntilFinalIsReady\) \{[\s\S]* \{\s*latestContentRef\.current = normalizedContent;\s*latestStreamingRef\.current = streaming;/, - "async rendering should only observe committed stream snapshots", + /markdownLoadFailedRef\.current/, + "completed Markdown rendering should retain its failure guard", ); assert.match( rendererSource, - /markdownLoadFailedRef\.current = true[\s\S]*markdownLoadFailedRef\.current/, - "a failed Markdown chunk should stop repeated stream retries", + /markdownLoadFailedRef\.current = true/, + "a failed completed-reply render should trip the failure guard", ); }); diff --git a/crates/ironclaw_webui/frontend/src/pages/chat/components/markdown-renderer.tsx b/crates/ironclaw_webui/frontend/src/pages/chat/components/markdown-renderer.tsx index 0fd7ea79a9..fa67d61fb2 100644 --- a/crates/ironclaw_webui/frontend/src/pages/chat/components/markdown-renderer.tsx +++ b/crates/ironclaw_webui/frontend/src/pages/chat/components/markdown-renderer.tsx @@ -1,10 +1,10 @@ // @ts-nocheck import React from "react"; +import "streamdown/styles.css"; import { toast } from "../../../lib/toast"; import { useT } from "../../../lib/i18n"; const COLLAPSE_PX = 360; -const STREAMING_RENDER_INTERVAL_MS = 150; /* Enhance rendered
 code blocks in place: syntax highlight, a hover
    toolbar (copy + soft-wrap toggle), and collapse for very tall blocks.
@@ -142,19 +142,16 @@ function MarkdownRendererImpl({ content, className = "", streaming = false }) {
   const normalizedContent = typeof content === "string" ? content : "";
   const [rendered, setRendered] = React.useState(null);
   const latestContentRef = React.useRef(normalizedContent);
-  const latestStreamingRef = React.useRef(streaming);
   const mountedRef = React.useRef(true);
   const renderInFlightRef = React.useRef(false);
   const markdownLoadFailedRef = React.useRef(false);
-  const lastRenderAtRef = React.useRef(0);
-  const lastRenderedSourceRef = React.useRef(null);
-  const renderTimerRef = React.useRef(null);
   const requestRenderRef = React.useRef(() => false);
-  const scheduleStreamingRenderRef = React.useRef(() => {});
+  const wasStreamingRef = React.useRef(streaming);
+  if (streaming) wasStreamingRef.current = true;
 
   const renderedHtml =
     normalizedContent && rendered &&
-    (streaming || rendered.source === normalizedContent)
+    rendered.source === normalizedContent
       ? rendered.html
       : null;
 
@@ -171,8 +168,6 @@ function MarkdownRendererImpl({ content, className = "", streaming = false }) {
       .then(({ renderMarkdown }) => {
         const currentContent = latestContentRef.current;
         if (!mountedRef.current || !currentContent) return;
-        lastRenderAtRef.current = Date.now();
-        lastRenderedSourceRef.current = currentContent;
         setRendered({
           source: currentContent,
           html: renderMarkdown(currentContent),
@@ -184,62 +179,26 @@ function MarkdownRendererImpl({ content, className = "", streaming = false }) {
       })
       .finally(() => {
         renderInFlightRef.current = false;
-        if (mountedRef.current && latestStreamingRef.current) {
-          scheduleStreamingRenderRef.current();
-        }
       });
     return true;
   };
 
-  scheduleStreamingRenderRef.current = () => {
-    if (
-      renderTimerRef.current !== null ||
-      renderInFlightRef.current ||
-      markdownLoadFailedRef.current ||
-      latestContentRef.current === lastRenderedSourceRef.current ||
-      !latestContentRef.current
-    ) {
-      return;
-    }
-    const elapsed = Date.now() - lastRenderAtRef.current;
-    const delay = Math.max(0, STREAMING_RENDER_INTERVAL_MS - elapsed);
-    renderTimerRef.current = setTimeout(() => {
-      renderTimerRef.current = null;
-      requestRenderRef.current();
-    }, delay);
-  };
-
-  // Streaming projections update for every chunk. Render a sanitized snapshot
-  // at a bounded cadence so Markdown remains legible while avoiding the full
-  // marked + DOMPurify pipeline for every projection. The first snapshot
-  // safely falls back to escaped React text while the lazy module loads.
-  // Keep the latest snapshot commit-scoped so a discarded concurrent render
-  // cannot leak its content into an async Markdown render.
+  // Streaming projections carry the full accumulated reply. The product
+  // boundary limits replaceable text snapshots to browser-paint cadence, so
+  // Streamdown can reconcile incomplete Markdown without a transition being
+  // continually superseded by provider microbursts. Completed replies keep the
+  // existing marked + DOMPurify path and code-block enhancements.
   React.useEffect(() => {
     latestContentRef.current = normalizedContent;
-    latestStreamingRef.current = streaming;
-  }, [normalizedContent, streaming]);
+  }, [normalizedContent]);
 
   React.useEffect(() => {
     if (!normalizedContent) {
-      if (renderTimerRef.current !== null) {
-        clearTimeout(renderTimerRef.current);
-        renderTimerRef.current = null;
-      }
-      lastRenderedSourceRef.current = null;
       setRendered(null);
       return;
     }
 
-    if (streaming) {
-      scheduleStreamingRenderRef.current();
-      return;
-    }
-
-    if (renderTimerRef.current !== null) {
-      clearTimeout(renderTimerRef.current);
-      renderTimerRef.current = null;
-    }
+    if (streaming) return;
     requestRenderRef.current();
   }, [normalizedContent, streaming]);
 
@@ -266,10 +225,32 @@ function MarkdownRendererImpl({ content, className = "", streaming = false }) {
     mountedRef.current = true;
     return () => {
       mountedRef.current = false;
-      if (renderTimerRef.current !== null) clearTimeout(renderTimerRef.current);
     };
   }, []);
 
+  const keepStreamingRendererUntilFinalIsReady =
+    !streaming && wasStreamingRef.current && renderedHtml === null;
+  if (streaming || keepStreamingRendererUntilFinalIsReady) {
+    return (
+      
+ {normalizedContent}
+ )} + > + + {normalizedContent} + + +
+ ); + } + if (renderedHtml === null) { return (
+ import("streamdown").then(({ Streamdown }) => ({ default: Streamdown })) +); + // Memoized so a bubble whose `content`/`className`/`streaming` are unchanged skips // re-rendering when sibling messages update (e.g. a new streaming chunk // elsewhere in the list). diff --git a/crates/ironclaw_webui/frontend/src/pages/chat/components/message-bubble.test.ts b/crates/ironclaw_webui/frontend/src/pages/chat/components/message-bubble.test.ts index 625ac28335..bbdbf7046e 100644 --- a/crates/ironclaw_webui/frontend/src/pages/chat/components/message-bubble.test.ts +++ b/crates/ironclaw_webui/frontend/src/pages/chat/components/message-bubble.test.ts @@ -1,6 +1,10 @@ +// @vitest-environment happy-dom + import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; -import React from "react"; +import { resolve } from "node:path"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; import { renderToStaticMarkup } from "react-dom/server"; import { test, vi } from "vitest"; @@ -9,6 +13,8 @@ import { type ErrorChatMessage, } from "../lib/message-types"; +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + vi.mock("./markdown-renderer", async () => { const { createElement } = await import("react"); return { @@ -67,11 +73,11 @@ vi.mock("./attachment-preview", async () => { }); const messageBubbleSource = readFileSync( - new URL("./message-bubble.tsx", import.meta.url), + resolve(process.cwd(), "src/pages/chat/components/message-bubble.tsx"), "utf8", ); const appCssSource = readFileSync( - new URL("../../../styles/app.css", import.meta.url), + resolve(process.cwd(), "src/styles/app.css"), "utf8", ); @@ -103,7 +109,11 @@ test("assistant bubbles expose final reply state for live QA", () => { test("assistant bubbles keep streaming projections off the full markdown path", async () => { const { MessageBubble } = await import("./message-bubble"); - const render = (isFinalReply: boolean, activeRunId: string | null) => + const render = ( + isFinalReply: boolean, + activeRunId: string | null, + isStreaming?: boolean, + ) => renderToStaticMarkup( React.createElement(MessageBubble, { message: { @@ -112,6 +122,7 @@ test("assistant bubbles keep streaming projections off the full markdown path", content: '', turnRunId: "run-1", isFinalReply, + isStreaming, }, activeRunId, }), @@ -124,6 +135,11 @@ test("assistant bubbles keep streaming projections off the full markdown path", /data-streaming="false"/, "historical assistant drafts must render through the completed Markdown path", ); + assert.match( + render(false, "run-1", false), + /data-streaming="false"/, + "an earlier model phase must stop using the streaming Markdown path while the run continues", + ); }); test("thinking bubbles defer Markdown only for the active run", async () => { @@ -146,43 +162,107 @@ test("thinking bubbles defer Markdown only for the active run", async () => { }); test("active reasoning in an activity run defers Markdown", async () => { - const { ActivityRun } = await import("./activity-run"); - const render = (activeRunId: string | null) => - renderToStaticMarkup( - React.createElement(ActivityRun, { - activeRunId, - activity: [ - { - id: "thinking-run-1", - role: CHAT_MESSAGE_ROLES.THINKING, - content: "Working on **this**.", - turnRunId: "run-1", - }, - ], - }), - ); + const activity = [ + { + id: "thinking-run-1", + role: CHAT_MESSAGE_ROLES.THINKING, + content: "Working on **this**.", + turnRunId: "run-1", + }, + ]; - assert.match(render("run-1"), /data-streaming="true"/); - assert.match(render(null), /data-streaming="false"/); + assert.match( + await renderExpandedActivity(activity, "run-1"), + /data-streaming="true"/, + ); + assert.match( + await renderExpandedActivity(activity, null), + /data-streaming="false"/, + ); }); test("untagged reasoning in an activity run renders Markdown", async () => { - const { ActivityRun } = await import("./activity-run"); - const markup = renderToStaticMarkup( - React.createElement(ActivityRun, { - activity: [ - { - id: "thinking-history", - role: CHAT_MESSAGE_ROLES.THINKING, - content: "Completed **reasoning**.", - }, - ], - }), - ); + const markup = await renderExpandedActivity([ + { + id: "thinking-history", + role: CHAT_MESSAGE_ROLES.THINKING, + content: "Completed **reasoning**.", + }, + ]); assert.match(markup, /data-streaming="false"/); }); +test("incoming reasoning and tool failures do not expand an activity run", async () => { + const { ActivityRun } = await import("./activity-run"); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + const render = (activity) => { + act(() => { + root.render(React.createElement(ActivityRun, { activity })); + }); + assert.equal( + container + .querySelector('[data-testid="activity-run-toggle"]') + ?.getAttribute("aria-expanded"), + "false", + ); + }; + + try { + render([ + { + id: "tool-search", + role: CHAT_MESSAGE_ROLES.TOOL_ACTIVITY, + toolName: "web-access.search", + toolStatus: "running", + }, + ]); + render([ + { + id: "reasoning", + role: CHAT_MESSAGE_ROLES.THINKING, + content: "Checking another source.", + }, + ]); + render([ + { + id: "tool-search", + role: CHAT_MESSAGE_ROLES.TOOL_ACTIVITY, + toolName: "web-access.search", + toolStatus: "error", + }, + ]); + } finally { + act(() => root.unmount()); + container.remove(); + } +}); + +async function renderExpandedActivity(activity, activeRunId: string | null = null) { + const { ActivityRun } = await import("./activity-run"); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + try { + act(() => { + root.render(React.createElement(ActivityRun, { activity, activeRunId })); + }); + const toggle = container.querySelector( + '[data-testid="activity-run-toggle"]', + ); + assert.equal(toggle?.getAttribute("aria-expanded"), "false"); + act(() => toggle?.click()); + assert.equal(toggle?.getAttribute("aria-expanded"), "true"); + return container.innerHTML; + } finally { + act(() => root.unmount()); + container.remove(); + } +} + test("only final assistant replies expose the run artifact download", async () => { const { MessageBubble } = await import("./message-bubble"); const render = (isFinalReply: boolean) => @@ -383,6 +463,30 @@ test("message timestamp and actions share a hover-only meta row", () => { ); }); +test("intermediate assistant phases do not reserve a hidden meta row", async () => { + const { MessageBubble } = await import("./message-bubble"); + const html = renderToStaticMarkup( + React.createElement(MessageBubble, { + message: { + id: "assistant-phase", + role: CHAT_MESSAGE_ROLES.ASSISTANT, + content: "I will check that.", + timestamp: "2026-07-30T00:00:00.000Z", + turnRunId: "run-1", + isFinalReply: false, + isStreaming: false, + }, + activeRunId: "run-1", + }), + ); + + assert.doesNotMatch( + html, + / { assert.match( messageBubbleSource, diff --git a/crates/ironclaw_webui/frontend/src/pages/chat/components/message-bubble.tsx b/crates/ironclaw_webui/frontend/src/pages/chat/components/message-bubble.tsx index 2d2e98d94f..b3bbe7e8f8 100644 --- a/crates/ironclaw_webui/frontend/src/pages/chat/components/message-bubble.tsx +++ b/crates/ironclaw_webui/frontend/src/pages/chat/components/message-bubble.tsx @@ -106,6 +106,7 @@ function MessageBubbleImpl({ const isStreamingAssistantReply = role === CHAT_MESSAGE_ROLES.ASSISTANT && message.isFinalReply === false && + message.isStreaming !== false && messageBelongsToActiveRun(message, activeRunId); const isStreamingThinking = role === CHAT_MESSAGE_ROLES.THINKING && @@ -209,10 +210,15 @@ function MessageBubbleImpl({ ); } - const timeLabel = formatTimestamp(timestamp); + const isIntermediateAssistantPhase = + role === CHAT_MESSAGE_ROLES.ASSISTANT && + message.isFinalReply === false; + const timeLabel = isIntermediateAssistantPhase ? "" : formatTimestamp(timestamp); const showActions = role === CHAT_MESSAGE_ROLES.USER || - (role === CHAT_MESSAGE_ROLES.ASSISTANT && !isOptimistic); + (role === CHAT_MESSAGE_ROLES.ASSISTANT && + !isOptimistic && + !isIntermediateAssistantPhase); const showArtifactAction = Boolean( role === CHAT_MESSAGE_ROLES.ASSISTANT && message.isFinalReply === true && diff --git a/crates/ironclaw_webui/frontend/src/pages/chat/hooks/useChat.ts b/crates/ironclaw_webui/frontend/src/pages/chat/hooks/useChat.ts index b3d0b3b3a9..fb44bf9062 100644 --- a/crates/ironclaw_webui/frontend/src/pages/chat/hooks/useChat.ts +++ b/crates/ironclaw_webui/frontend/src/pages/chat/hooks/useChat.ts @@ -450,6 +450,7 @@ export function useChat(threadId) { threadId, onEvent: handleEvent, enabled: Boolean(threadId), + activityExpected: isProcessing, }); React.useEffect(() => { diff --git a/crates/ironclaw_webui/frontend/src/pages/chat/hooks/useHistory.ts b/crates/ironclaw_webui/frontend/src/pages/chat/hooks/useHistory.ts index f5c11aa61e..98e8782dcc 100644 --- a/crates/ironclaw_webui/frontend/src/pages/chat/hooks/useHistory.ts +++ b/crates/ironclaw_webui/frontend/src/pages/chat/hooks/useHistory.ts @@ -476,6 +476,22 @@ function mergeFullRefresh(fresh, current, options = {}) { .map((message) => message?.turnRunId) .filter((runId) => typeof runId === "string"), ); + const currentFinalizedRunIds = new Set( + current + .filter(isFinalAssistantMessage) + .map((message) => message?.turnRunId) + .filter((runId) => typeof runId === "string"), + ); + const latestLiveAssistantIndexByFinalizedRun = new Map(); + current.forEach((message, index) => { + if ( + isLiveAssistantMessage(message) && + finalizedRunIds.has(message.turnRunId) && + !currentFinalizedRunIds.has(message.turnRunId) + ) { + latestLiveAssistantIndexByFinalizedRun.set(message.turnRunId, index); + } + }); const freshSequenceWindow = rawFreshSequenceWindow || timelineSequenceWindow(hydratedFresh); const currentSequenceWindow = @@ -487,7 +503,7 @@ function mergeFullRefresh(fresh, current, options = {}) { freshSequenceWindow, currentSequenceWindow, ); - const preserved = current.filter((message) => { + const preserved = current.filter((message, index) => { if (!message || typeof message.id !== "string" || ids.has(message.id)) { return false; } @@ -500,11 +516,10 @@ function mergeFullRefresh(fresh, current, options = {}) { ) { return false; } - if ( - isLiveAssistantMessage(message) && - !finalizedRunIds.has(message.turnRunId) - ) { - return true; + if (isLiveAssistantMessage(message)) { + if (!finalizedRunIds.has(message.turnRunId)) return true; + if (currentFinalizedRunIds.has(message.turnRunId)) return true; + return latestLiveAssistantIndexByFinalizedRun.get(message.turnRunId) !== index; } if (isSeededOptimisticMessage(message)) return true; if ( @@ -700,7 +715,10 @@ function insertPreservedAtOriginalPositions(fresh, preserved, current) { const append = []; for (const message of anchoredPreserved) { - if (!isRunActivityMessage(message)) { + if ( + !isRunActivityMessage(message) && + !isLiveAssistantMessage(message) + ) { append.push(message); continue; } diff --git a/crates/ironclaw_webui/frontend/src/pages/chat/hooks/useSSE.ts b/crates/ironclaw_webui/frontend/src/pages/chat/hooks/useSSE.ts index 7e50d30d56..1846fad7f1 100644 --- a/crates/ironclaw_webui/frontend/src/pages/chat/hooks/useSSE.ts +++ b/crates/ironclaw_webui/frontend/src/pages/chat/hooks/useSSE.ts @@ -1,40 +1,12 @@ import React from "react"; -import { clientActionId, openEventStream } from "../../../lib/api"; +import { EventSourcePlus } from "event-source-plus"; +import { clientActionId, eventStreamRequest } from "../../../lib/api"; import { CONNECTION_STATUS, type ConnectionStatus, } from "../lib/connection-status"; -// v2 SSE emits `WebChatV2EventFrame` JSON, tagged with a typed -// event name (`event: accepted`, `event: final_reply`, etc.) so -// each frame routes to its `addEventListener("", …)` handler. -// `onmessage` would only catch frames without an `event:` field, -// which the Rust handler never emits — so the SPA must register a -// listener for every event name it cares about. The names below -// mirror `WebChatV2Event::event_name()` in -// `crates/ironclaw_webui/src/webui_v2/schema.rs`. -const V2_EVENT_NAMES = [ - "accepted", - "running", - "capability_progress", - "capability_activity", - "capability_display_preview", - "gate", - "auth_required", - "final_reply", - "cancelled", - "failed", - "projection_snapshot", - "projection_update", - "keep_alive", - "stream_error", -]; - -const EVENT_SOURCE_CLOSED = 2; -const EVENT_SOURCE_OPEN = 1; -// Stable for this browser tab's loaded SPA. Reusing it across Chat route -// mounts lets the server supersede a proxy-held stream from the prior thread -// instead of rejecting the replacement against the per-user connection cap. +const ACTIVE_STREAM_STALL_DEADLINE_MS = 30_000; const SSE_CONNECTION_ID = clientActionId(); let nextConnectionGeneration = 0; @@ -46,238 +18,199 @@ function connectionGeneration() { return nextConnectionGeneration; } -function eventSourceReadyStateConstant(staticValue: unknown, fallback: number) { - return typeof staticValue === "number" ? staticValue : fallback; -} - -function isEventSourceClosed(source) { - const closedState = typeof EventSource === "function" - ? eventSourceReadyStateConstant(EventSource.CLOSED, EVENT_SOURCE_CLOSED) - : EVENT_SOURCE_CLOSED; - return source?.readyState === closedState; -} - -function isEventSourceOpen(source) { - const openState = typeof EventSource === "function" - ? eventSourceReadyStateConstant(EventSource.OPEN, EVENT_SOURCE_OPEN) - : EVENT_SOURCE_OPEN; - return source?.readyState === openState; -} - function isBrowserOffline() { return typeof navigator !== "undefined" && navigator.onLine === false; } -export function useSSE({ threadId, onEvent, enabled }) { +function isRetryableResponseStatus(status) { + return ( + status === 204 || + status === 408 || + status === 425 || + status === 429 || + status >= 500 + ); +} + +export function useSSE({ + threadId, + onEvent, + enabled, + activityExpected = false, +}) { const [status, setStatus] = React.useState( CONNECTION_STATUS.IDLE, ); const onEventRef = React.useRef(onEvent); onEventRef.current = onEvent; + const activityExpectedRef = React.useRef(activityExpected); + activityExpectedRef.current = activityExpected; + const syncActivityWatchdogRef = React.useRef(() => {}); React.useEffect(() => { if (!enabled || !threadId) { setStatus(CONNECTION_STATUS.IDLE); return; } - let es = null; - let reconnectTimer = null; - let openWatchdog = null; - let reconnectAttempts = 0; + let controller = null; + let activityWatchdog = null; let disposed = false; let terminalErrorReceived = false; - // This cursor belongs to this mounted route only. A route remount must - // hydrate current state from the projection origin because the composite - // cursor contains a process-local live rail. Native EventSource retries - // still carry Last-Event-ID automatically, and explicit retries within - // this effect may resume from the latest frame observed here. - let lastEventId = null; - const maxReconnectDelay = 30_000; - const reconnectOpenDeadline = 10_000; + let connectedOnce = false; + let streamOpen = false; + const request = eventStreamRequest({ + threadId, + connectionId: SSE_CONNECTION_ID, + }); + const stream = new EventSourcePlus(request.url, { + credentials: "same-origin", + headers: request.headers, + maxRetryInterval: 30_000, + retryStrategy: "always", + }); - function clearOpenWatchdog() { - if (openWatchdog) { - clearTimeout(openWatchdog); - openWatchdog = null; + function clearActivityWatchdog() { + if (activityWatchdog) { + clearTimeout(activityWatchdog); + activityWatchdog = null; } } - function markConnected(source) { - if (disposed || terminalErrorReceived || es !== source) return; - clearOpenWatchdog(); - reconnectAttempts = 0; - setStatus(CONNECTION_STATUS.CONNECTED); + function activityIsExpected() { + return activityExpectedRef.current === true; } - function scheduleOpenWatchdog(source) { - if (openWatchdog) return; - openWatchdog = setTimeout(() => { - openWatchdog = null; - if (disposed || terminalErrorReceived || es !== source) return; - if (isEventSourceOpen(source)) { - markConnected(source); + function scheduleActivityWatchdog() { + clearActivityWatchdog(); + if ( + disposed || + terminalErrorReceived || + !streamOpen || + !activityIsExpected() + ) { + return; + } + activityWatchdog = setTimeout(() => { + activityWatchdog = null; + if ( + disposed || + terminalErrorReceived || + !streamOpen || + !activityIsExpected() + ) { return; } - reconnectWithTimer(CONNECTION_STATUS.RECONNECTING); - }, reconnectOpenDeadline); + setStatus(CONNECTION_STATUS.RECONNECTING); + controller.reconnect(); + }, ACTIVE_STREAM_STALL_DEADLINE_MS); } - function reconnectWithTimer( - status: ConnectionStatus = CONNECTION_STATUS.DISCONNECTED, - ) { + function markConnected() { if (disposed || terminalErrorReceived) return; - if (es) { - es.close(); - es = null; - } - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - clearOpenWatchdog(); - setStatus(status); - reconnectAttempts++; - const delay = Math.min(1000 * 2 ** reconnectAttempts, maxReconnectDelay); - reconnectTimer = setTimeout(connect, delay); + connectedOnce = true; + setStatus(CONNECTION_STATUS.CONNECTED); } function connect() { - reconnectTimer = null; if (disposed || terminalErrorReceived) return; if (document.visibilityState === "hidden") { + streamOpen = false; setStatus(CONNECTION_STATUS.PAUSED); return; } setStatus( - isBrowserOffline() || reconnectAttempts > 0 + isBrowserOffline() ? CONNECTION_STATUS.RECONNECTING : CONNECTION_STATUS.CONNECTING, ); - - es = openEventStream({ - threadId, - afterCursor: lastEventId || undefined, - connectionId: SSE_CONNECTION_ID, - connectionGeneration: connectionGeneration(), - }); - const source = es; - - // A replacement EventSource can remain in CONNECTING forever without - // firing either callback when a proxy accepts the HTTP request but does - // not establish the event stream. Bound reconnect attempts explicitly; - // the initial connection remains browser-managed, while every recovery - // attempt must prove it opened within this deadline. - if (reconnectAttempts > 0) scheduleOpenWatchdog(source); - - source.onopen = () => { - markConnected(source); - }; - - const dispatchFrame = (event, fallbackType) => { - if (disposed || es !== source) return; - let frame = null; - try { - frame = JSON.parse(event.data); - } catch (_) { - return; - } - if (!frame || typeof frame !== "object") return; - if (event.lastEventId) { - lastEventId = event.lastEventId; - } - const rawType = frame.type || fallbackType; - const type = rawType === "stream_error" ? "error" : rawType; - // Some browsers resume an interrupted EventSource by delivering the - // next frame without a second `open` callback. A normal frame proves - // the transport recovered and must clear a stale reconnecting badge. - // Classified stream errors keep their own terminal/retry state below. - if (type !== "error") markConnected(source); - onEventRef.current?.({ - // The frame's own `type` field is the canonical source; - // `event.type` (from the SSE `event:` line) is the - // fallback for forwards-compatibility if Rust adds an - // event without setting `type` in the body. - type, - frame, - lastEventId: event.lastEventId || null, - }); - // The server has already classified this failure as permanent for - // this subscription (for example, a thread that no longer exists). - // EventSource reports the subsequent clean server close through - // `onerror`; remember the terminal frame and close locally so that - // callback cannot turn a non-retryable response into an infinite - // reconnect loop. - if (type === "error" && frame.retryable === false && es === source) { - terminalErrorReceived = true; - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; + controller = stream.listen({ + onRequest({ options }) { + if (disposed || terminalErrorReceived) return; + options.query = { + ...options.query, + connection_generation: connectionGeneration(), + }; + setStatus( + connectedOnce + ? CONNECTION_STATUS.RECONNECTING + : CONNECTION_STATUS.CONNECTING, + ); + }, + onRequestError() { + if (disposed || terminalErrorReceived) return; + setStatus(CONNECTION_STATUS.RECONNECTING); + }, + onResponse({ response }) { + if (disposed || terminalErrorReceived) return; + if ( + response.ok && + response.headers.get("content-type")?.includes("text/event-stream") + ) { + markConnected(); + } + }, + onResponseError({ response }) { + if (disposed || terminalErrorReceived) return; + if (isRetryableResponseStatus(response.status)) { + setStatus(CONNECTION_STATUS.RECONNECTING); + return; } - es = null; - source.close(); + terminalErrorReceived = true; + streamOpen = false; + clearActivityWatchdog(); + controller?.abort("non-retryable stream response"); setStatus(CONNECTION_STATUS.DISCONNECTED); - return; - } - // A replay-unavailable response means retrying the same cursor can - // never make progress. Replace this EventSource so the browser drops - // its internal Last-Event-ID, and reconnect from the projection origin - // where durable run/final-reply state can be rebuilt. - if ( - type === "error" && - frame.kind === "replay_unavailable" && - frame.retryable === true && - es === source - ) { - lastEventId = null; - reconnectWithTimer(CONNECTION_STATUS.RECONNECTING); - } - }; - - source.onerror = (event) => { - if (disposed || terminalErrorReceived || es !== source) return; - // Compatibility with servers that emitted application failures on - // the reserved `event: error` channel. Those arrive as MessageEvents - // with data; a native EventSource transport failure has no data. - // Parsing the former here prevents one browser event from also - // entering the transport reconnect state machine. - if (typeof event?.data === "string") { - dispatchFrame(event, "error"); - return; - } - // Preserve EventSource's native retry for transient failures. Closing - // it immediately creates a fresh HTTP stream for every error, which can - // race server-side slot release behind a proxy and strand the client in - // a reconnect loop. The watchdog below remains the bounded fallback if - // native recovery never opens or delivers another frame. - if (!isEventSourceClosed(source)) { - setStatus(CONNECTION_STATUS.RECONNECTING); - scheduleOpenWatchdog(source); - return; - } - reconnectWithTimer(); - }; - - // Cover anything emitted without an `event:` field — defensive - // only; the Rust handler always tags its frames today. - es.onmessage = (event) => dispatchFrame(event, "message"); - - // The Rust handler tags each frame with `event: ` so the - // browser routes it through the named listener below. - for (const name of V2_EVENT_NAMES) { - es.addEventListener(name, (event) => dispatchFrame(event, name)); + }, + onMessage(message) { + if (disposed || terminalErrorReceived) return; + let frame = null; + try { + frame = JSON.parse(message.data); + } catch (_) { + return; + } + if (!frame || typeof frame !== "object") return; + const rawType = frame.type || message.event || "message"; + const type = rawType === "stream_error" ? "error" : rawType; + if (type !== "error") markConnected(); + onEventRef.current?.({ + type, + frame, + lastEventId: message.id || null, + }); + scheduleActivityWatchdog(); + if (type === "error" && frame.retryable === false) { + terminalErrorReceived = true; + streamOpen = false; + clearActivityWatchdog(); + controller?.abort("non-retryable stream event"); + setStatus(CONNECTION_STATUS.DISCONNECTED); + return; + } + if ( + type === "error" && + frame.kind === "replay_unavailable" && + frame.retryable === true + ) { + stream.lastEventId = undefined; + setStatus(CONNECTION_STATUS.RECONNECTING); + controller?.reconnect(); + } + }, + }); + if (terminalErrorReceived) { + streamOpen = false; + controller?.abort("non-retryable stream response"); + return; } + streamOpen = true; + scheduleActivityWatchdog(); } function disconnectForHiddenTab() { if (disposed || terminalErrorReceived) return; - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - clearOpenWatchdog(); - if (es) { - es.close(); - es = null; - } + streamOpen = false; + clearActivityWatchdog(); + controller?.abort("document hidden"); setStatus(CONNECTION_STATUS.PAUSED); } @@ -285,8 +218,13 @@ export function useSSE({ threadId, onEvent, enabled }) { if (disposed || terminalErrorReceived) return; if (document.visibilityState === "hidden") { disconnectForHiddenTab(); - } else if (!es) { + } else if (!controller) { connect(); + } else { + streamOpen = true; + setStatus(CONNECTION_STATUS.CONNECTING); + controller.reconnect(); + scheduleActivityWatchdog(); } } @@ -297,22 +235,17 @@ export function useSSE({ threadId, onEvent, enabled }) { function handleNetworkOnline() { if (disposed || terminalErrorReceived) return; - if (es && isEventSourceOpen(es)) { - markConnected(es); - return; - } setStatus(CONNECTION_STATUS.RECONNECTING); - if (es) { - scheduleOpenWatchdog(es); - return; - } - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - connect(); + controller?.reconnect(); } + syncActivityWatchdogRef.current = () => { + if (streamOpen) { + scheduleActivityWatchdog(); + } else { + clearActivityWatchdog(); + } + }; connect(); document.addEventListener("visibilitychange", handleVisibilityChange); window.addEventListener("offline", handleNetworkOffline); @@ -320,16 +253,23 @@ export function useSSE({ threadId, onEvent, enabled }) { return () => { disposed = true; + streamOpen = false; document.removeEventListener("visibilitychange", handleVisibilityChange); window.removeEventListener("offline", handleNetworkOffline); window.removeEventListener("online", handleNetworkOnline); - if (reconnectTimer) clearTimeout(reconnectTimer); - clearOpenWatchdog(); - const source = es; - es = null; - source?.close(); + clearActivityWatchdog(); + syncActivityWatchdogRef.current = () => {}; + controller?.abort("component disposed"); + controller = null; }; }, [enabled, threadId]); + React.useEffect(() => { + // A send can begin after an idle stream has already gone half-open, + // so no accepted/running frame is available to arm the watchdog. Sync it + // when processing state changes as well as when frames arrive. + syncActivityWatchdogRef.current(); + }, [activityExpected, enabled, threadId]); + return { status }; } diff --git a/crates/ironclaw_webui/frontend/src/pages/chat/lib/message-groups.test.ts b/crates/ironclaw_webui/frontend/src/pages/chat/lib/message-groups.test.ts index d5e53cef68..c1abe93b47 100644 --- a/crates/ironclaw_webui/frontend/src/pages/chat/lib/message-groups.test.ts +++ b/crates/ironclaw_webui/frontend/src/pages/chat/lib/message-groups.test.ts @@ -3,6 +3,41 @@ import { test } from "vitest"; import { groupMessages } from "./message-groups"; +test("groupMessages: live and final replies share a stable render key", () => { + const live = groupMessages([ + { + id: "text-text:run-1:2", + role: "assistant", + content: "Here is the final", + isFinalReply: false, + isStreaming: true, + turnRunId: "run-1", + }, + ]); + const finalized = groupMessages([ + { + id: "reply-run-1", + role: "assistant", + content: "Here is the final answer.", + isFinalReply: true, + turnRunId: "run-1", + }, + ]); + const refreshed = groupMessages([ + { + id: "msg-assistant-1", + role: "assistant", + content: "Here is the final answer.", + isFinalReply: true, + turnRunId: "run-1", + }, + ]); + + assert.equal(live[0].id, "assistant-reply-run-1"); + assert.equal(finalized[0].id, live[0].id); + assert.equal(refreshed[0].id, live[0].id); +}); + test("groupMessages: consecutive tool_activity messages collapse into one run", () => { const grouped = groupMessages([ { id: "a", role: "tool_activity", toolName: "read" }, @@ -161,6 +196,70 @@ test("groupMessages: middle assistant is not hoisted before final reply arrives" assert.deepEqual(grouped[1].activity.map((item) => item.id), ["a", "b"]); }); +test("groupMessages: empty intermediate assistant phases do not create blank rows", () => { + const grouped = groupMessages([ + { + id: "a", + role: "tool_activity", + toolName: "web_search", + turnRunId: "run-1", + }, + { + id: "empty-phase", + role: "assistant", + content: "", + isFinalReply: false, + isStreaming: false, + turnRunId: "run-1", + }, + { + id: "b", + role: "tool_activity", + toolName: "http", + turnRunId: "run-1", + }, + ]); + + assert.equal(grouped.length, 1); + assert.equal(grouped[0].type, "activity-run"); + assert.deepEqual(grouped[0].activity.map((item) => item.id), ["a", "b"]); +}); + +test("groupMessages: empty intermediate error phases remain visible", () => { + const grouped = groupMessages([ + { + id: "failed-phase", + role: "assistant", + content: "", + status: "error", + isFinalReply: false, + isStreaming: false, + turnRunId: "run-1", + }, + ]); + + assert.equal(grouped.length, 1); + assert.equal(grouped[0].type, "message"); + assert.equal(grouped[0].message.id, "failed-phase"); +}); + +test("groupMessages: intermediate assistant attachments remain visible", () => { + const grouped = groupMessages([ + { + id: "attachment-phase", + role: "assistant", + content: "", + attachments: [{ id: "file-1", name: "result.txt" }], + isFinalReply: false, + turnRunId: "run-1", + }, + ]); + + assert.equal(grouped.length, 1); + assert.equal(grouped[0].type, "message"); + assert.equal(grouped[0].message.id, "attachment-phase"); +}); + test("groupMessages: follow-up user does not break prior final reply ordering", () => { const grouped = groupMessages([ { id: "m", role: "assistant", content: "answer", isFinalReply: true }, diff --git a/crates/ironclaw_webui/frontend/src/pages/chat/lib/message-groups.ts b/crates/ironclaw_webui/frontend/src/pages/chat/lib/message-groups.ts index 490525238e..0e8460d12d 100644 --- a/crates/ironclaw_webui/frontend/src/pages/chat/lib/message-groups.ts +++ b/crates/ironclaw_webui/frontend/src/pages/chat/lib/message-groups.ts @@ -4,7 +4,11 @@ final answer, including when a later user follow-up has already been appended. */ export function groupMessages(messages) { - const orderedMessages = moveDelayedActivityBeforeAssistantBoundary(messages); + const renderableMessages = messages.filter( + (message) => !isEmptyIntermediateAssistantPhase(message), + ); + const orderedMessages = + moveDelayedActivityBeforeAssistantBoundary(renderableMessages); const items = []; for (let index = 0; index < orderedMessages.length; index += 1) { @@ -100,7 +104,20 @@ function appendActivityRun(items, activity) { } function appendMessage(items, message) { - items.push({ type: "message", id: message.id, message }); + items.push({ type: "message", id: messageRenderKey(message), message }); +} + +function messageRenderKey(message) { + const runId = + typeof message?.turnRunId === "string" ? message.turnRunId : null; + const isActiveAssistantReply = + message?.role === "assistant" && + message?.isFinalReply === false && + message?.isStreaming === true; + if (runId && (isFinalAssistantReply(message) || isActiveAssistantReply)) { + return `assistant-reply-${runId}`; + } + return message.id; } function isFinalAssistantReply(msg) { @@ -126,6 +143,27 @@ function isStreamingAssistantText(msg) { ); } +function isEmptyIntermediateAssistantPhase(msg) { + // Intermediate phases are live run presentation, not transcript messages. + // A phase with no visible payload should not split adjacent activity into + // separate runs or reserve a blank row between tool groups. + const hasContent = + typeof msg?.content === "string" && msg.content.trim().length > 0; + const hasAttachments = + msg?.images?.length > 0 || + msg?.attachments?.length > 0 || + msg?.generatedImages?.length > 0; + return ( + msg?.role === "assistant" && + msg.isFinalReply === false && + !hasToolCalls(msg) && + !hasContent && + !hasAttachments && + !msg.error && + msg.status !== "error" + ); +} + function isActivity(msg) { return msg.role === "thinking" || msg.role === "tool_activity" || hasToolCalls(msg); } diff --git a/crates/ironclaw_webui/frontend/src/pages/chat/lib/stream-order-memory.ts b/crates/ironclaw_webui/frontend/src/pages/chat/lib/stream-order-memory.ts index a1290fc00c..6a71cd1b51 100644 --- a/crates/ironclaw_webui/frontend/src/pages/chat/lib/stream-order-memory.ts +++ b/crates/ironclaw_webui/frontend/src/pages/chat/lib/stream-order-memory.ts @@ -51,24 +51,33 @@ export function replaceAssistantReplyForRun(messages, replyMessage, runId) { const currentMessages = messages || []; if (!runId) return [...currentMessages, replyMessage]; - let replaced = false; - let changed = false; - const next = []; - for (const message of currentMessages) { - if (message?.role === "assistant" && message.turnRunId === runId) { - if (!replaced) { - replaced = true; - changed = true; - next.push(finalReplyReplacement(message, replyMessage)); - } else { - changed = true; + let replacementIndex = currentMessages.findIndex( + (message) => + isFinalAssistantMessage(message) && message.turnRunId === runId, + ); + if (replacementIndex < 0) { + for (let index = currentMessages.length - 1; index >= 0; index -= 1) { + const message = currentMessages[index]; + if ( + message?.role === "assistant" && + message.turnRunId === runId && + message.isFinalReply === false + ) { + replacementIndex = index; + break; } - continue; } - next.push(message); } - return replaced && changed ? next : [...currentMessages, replyMessage]; + if (replacementIndex < 0) { + return [...currentMessages, replyMessage]; + } + const next = [...currentMessages]; + next[replacementIndex] = finalReplyReplacement( + currentMessages[replacementIndex], + replyMessage, + ); + return next; } function finalReplyReplacement(current, replyMessage) { diff --git a/crates/ironclaw_webui/frontend/src/pages/chat/lib/useChatEvents.test.ts b/crates/ironclaw_webui/frontend/src/pages/chat/lib/useChatEvents.test.ts index 8cdc6b27c8..b709ebebc9 100644 --- a/crates/ironclaw_webui/frontend/src/pages/chat/lib/useChatEvents.test.ts +++ b/crates/ironclaw_webui/frontend/src/pages/chat/lib/useChatEvents.test.ts @@ -340,6 +340,78 @@ test("useChatEvents: final_reply replaces matching streamed projection bubble", assert.equal(harness.isProcessing, false); }); +test("useChatEvents: final_reply preserves earlier assistant phases and replaces only the latest", () => { + const harness = createUseChatEventsHarness(); + + harness.handleEvent({ + type: "projection_update", + frame: { + state: { + items: [ + { run_status: { run_id: "run-1", status: "running" } }, + { + text: { + id: "text:run-1:1", + run_id: "run-1", + body: "I’ll research this first.", + }, + }, + ], + }, + }, + }); + harness.handleEvent({ + type: "projection_update", + frame: { + state: { + items: [ + { + text: { + id: "text:run-1:2", + run_id: "run-1", + body: "Here is the final", + }, + }, + ], + }, + }, + }); + harness.handleEvent({ + type: "final_reply", + frame: { + reply: { + turn_run_id: "run-1", + text: "Here is the final answer.", + generated_at: "2026-07-08T13:00:00Z", + }, + }, + }); + + assert.deepEqual( + Array.from(harness.messages, (message) => ({ + id: message.id, + content: message.content, + isFinalReply: message.isFinalReply, + isStreaming: message.isStreaming, + })), + [ + { + id: "text-text:run-1:1", + content: "I’ll research this first.", + isFinalReply: false, + isStreaming: false, + }, + { + id: "reply-run-1", + content: "Here is the final answer.", + isFinalReply: true, + isStreaming: undefined, + }, + ], + ); + assert.equal(harness.isProcessing, false); +}); + test("useChatEvents: replayed final_reply replaces existing same-run assistant in place", () => { const harness = createUseChatEventsHarness(); harness.replaceMessages([ diff --git a/crates/ironclaw_webui/frontend/src/pages/chat/lib/useChatEvents.ts b/crates/ironclaw_webui/frontend/src/pages/chat/lib/useChatEvents.ts index 33e8a9a269..2bff5289e5 100644 --- a/crates/ironclaw_webui/frontend/src/pages/chat/lib/useChatEvents.ts +++ b/crates/ironclaw_webui/frontend/src/pages/chat/lib/useChatEvents.ts @@ -612,31 +612,42 @@ function applyProjectionItems({ const messageId = `text-${item.text.id}`; const textRunId = item.text.run_id || null; setMessages((prev) => { + const phaseAware = textRunId + ? prev.map((message) => + message?.role === "assistant" && + message.turnRunId === textRunId && + message.isFinalReply === false && + message.id !== messageId + ? { ...message, isStreaming: false } + : message, + ) + : prev; if ( textRunId && - prev.some((m) => isFinalAssistantForRun(m, textRunId)) + phaseAware.some((m) => isFinalAssistantForRun(m, textRunId)) ) { - return prev; + return phaseAware; } const timelineMessageId = item.text.id ? `msg-${item.text.id}` : null; - const existing = prev.findIndex( + const existing = phaseAware.findIndex( (m) => m.id === messageId || (timelineMessageId && m.id === timelineMessageId), ); const next = { - ...(existing >= 0 ? prev[existing] : {}), + ...(existing >= 0 ? phaseAware[existing] : {}), id: messageId, role: "assistant", content: item.text.body || "", - timestamp: prev[existing]?.timestamp || new Date().toISOString(), - turnRunId: prev[existing]?.turnRunId || textRunId, + timestamp: phaseAware[existing]?.timestamp || new Date().toISOString(), + turnRunId: phaseAware[existing]?.turnRunId || textRunId, isFinalReply: false, + isStreaming: true, }; if (existing >= 0) { - const copy = [...prev]; + const copy = [...phaseAware]; copy[existing] = next; return copy; } - return [...prev, next]; + return [...phaseAware, next]; }); } diff --git a/crates/ironclaw_webui/frontend/src/pages/chat/lib/useHistory.test.ts b/crates/ironclaw_webui/frontend/src/pages/chat/lib/useHistory.test.ts index 189f707331..24264b0c60 100644 --- a/crates/ironclaw_webui/frontend/src/pages/chat/lib/useHistory.test.ts +++ b/crates/ironclaw_webui/frontend/src/pages/chat/lib/useHistory.test.ts @@ -1188,6 +1188,74 @@ test("mergeFullRefresh keeps same-run activity before confirmed assistant replie assert.equal(merged[2].keepFollowingActivityAfter, undefined); }); +test("mergeFullRefresh preserves completed intermediate assistant phases", () => { + const context = { + globalThis: {}, + React: createReactStub(), + carryFinalAssistantOrderFlags, + isFinalAssistantMessage, + isRunActivityMessage, + }; + vm.runInNewContext(useHistorySourceForTest(), context); + const { mergeFullRefresh } = context.globalThis.__testExports; + + const merged = mergeFullRefresh( + [ + { + id: "msg-user-1", + role: "user", + turnRunId: "run-1", + }, + { + id: "tool-web-search", + role: "tool_activity", + toolName: "web_search", + turnRunId: "run-1", + }, + { + id: "msg-assistant-1", + role: "assistant", + content: "Here is the final answer.", + isFinalReply: true, + turnRunId: "run-1", + }, + ], + [ + { + id: "msg-user-1", + role: "user", + turnRunId: "run-1", + }, + { + id: "text-text:run-1:1", + role: "assistant", + content: "I’ll research this first.", + isFinalReply: false, + turnRunId: "run-1", + }, + { + id: "tool-web-search", + role: "tool_activity", + toolName: "web_search", + turnRunId: "run-1", + }, + { + id: "text-text:run-1:2", + role: "assistant", + content: "Here is the final", + isFinalReply: false, + turnRunId: "run-1", + }, + ], + ); + + assert.equal( + merged.map((message) => message.id).join(","), + "msg-user-1,text-text:run-1:1,tool-web-search,msg-assistant-1", + "the durable final replaces only the last live phase; earlier phases keep their original order", + ); +}); + test("mergeFullRefresh preserves final assistant activity-order flag by run", () => { const context = { globalThis: {}, diff --git a/crates/ironclaw_webui/frontend/src/pages/chat/lib/useSSE.test.ts b/crates/ironclaw_webui/frontend/src/pages/chat/lib/useSSE.test.ts index a5a4eb13ea..c85b83998b 100644 --- a/crates/ironclaw_webui/frontend/src/pages/chat/lib/useSSE.test.ts +++ b/crates/ironclaw_webui/frontend/src/pages/chat/lib/useSSE.test.ts @@ -28,55 +28,109 @@ function createHarness({ online = true, visibilityState = "visible", onEvent = () => {}, + activityExpected = false, } = {}) { const statuses = []; const streams = []; const timers = []; const documentListeners = new Map(); const windowListeners = new Map(); - let cleanup = null; let refIndex = 0; + let effectIndex = 0; + let currentThreadId = "thread-1"; + let currentActivityExpected = activityExpected; const refs = []; + const effects = []; + + class EventSourcePlus { + constructor(url, options) { + this.url = url; + this.options = options; + this.lastEventId = undefined; + this.requestOptions = {}; + streams.push(this); + } + + listen(hooks) { + this.hooks = hooks; + const stream = this; + this.controller = { + abortCalls: [], + reconnectCalls: 0, + abort(reason) { + this.abortCalls.push(reason); + }, + reconnect() { + this.reconnectCalls += 1; + stream.request(); + }, + }; + this.request(); + return this.controller; + } + + request() { + this.hooks.onRequest?.({ options: this.requestOptions }); + } - function EventSource() {} - EventSource.CONNECTING = 0; - EventSource.OPEN = 1; - EventSource.CLOSED = 2; + respond(status = 200, contentType = "text/event-stream") { + const response = { + ok: status >= 200 && status < 300, + status, + headers: new Headers({ "content-type": contentType }), + }; + if (response.ok) { + this.hooks.onResponse?.({ response }); + if (!contentType.includes("text/event-stream")) { + this.hooks.onResponseError?.({ response }); + } + } else { + this.hooks.onResponseError?.({ response }); + } + } + + message(frame, { event = frame.type, id = "" } = {}) { + if (id) this.lastEventId = id; + this.hooks.onMessage?.({ + data: JSON.stringify(frame), + event, + id, + }); + } + } const context = { CONNECTION_STATUS, - authScope: () => "tenant:user", clientActionId: () => "browser-tab-connection", - EventSource, + EventSourcePlus, + eventStreamRequest: ({ threadId, connectionId }) => ({ + url: `http://localhost/events/${threadId}?connection_id=${connectionId}`, + headers: () => ({ Authorization: "Bearer token-1" }), + }), + globalThis: {}, + Headers, JSON, Math, - globalThis: { - crypto: { randomUUID: () => "browser-tab-connection" }, - }, - openEventStream: (args) => { - const listeners = new Map(); - const stream = { - args, - readyState: EventSource.CONNECTING, - closeCalls: 0, - onmessage: null, - onopen: null, - onerror: null, - addEventListener: (name, handler) => listeners.set(name, handler), - close() { - this.closeCalls += 1; - this.readyState = EventSource.CLOSED; - }, - listener(name) { - return listeners.get(name); - }, - }; - streams.push(stream); - return stream; - }, React: { - useEffect: (effect) => { - cleanup = effect(); + useEffect: (effect, dependencies) => { + const index = effectIndex++; + const previous = effects[index]; + const changed = + !previous || + !dependencies || + !previous.dependencies || + dependencies.length !== previous.dependencies.length || + dependencies.some( + (dependency, dependencyIndex) => + !Object.is(dependency, previous.dependencies[dependencyIndex]), + ); + if (!changed) return; + previous?.cleanup?.(); + const cleanup = effect(); + effects[index] = { + dependencies: dependencies ? [...dependencies] : null, + cleanup: typeof cleanup === "function" ? cleanup : null, + }; }, useRef: (initial) => { const index = refIndex++; @@ -107,31 +161,42 @@ function createHarness({ vm.runInNewContext(useSSESourceForTest(), context); let result = null; - function render(threadId = "thread-1") { - cleanup?.(); - cleanup = null; + function cleanupEffects() { + for (const effect of effects.splice(0)) effect?.cleanup?.(); + } + function render( + threadId = currentThreadId, + nextActivityExpected = currentActivityExpected, + ) { + currentThreadId = threadId; + currentActivityExpected = nextActivityExpected; refIndex = 0; + effectIndex = 0; result = context.globalThis.__testExports.useSSE({ threadId, enabled: true, onEvent, + activityExpected: currentActivityExpected, }); return result; } function remount(threadId = "thread-1") { + cleanupEffects(); refs.length = 0; return render(threadId); } render(); return { - cleanup: () => cleanup?.(), + cleanup: cleanupEffects, context, documentListeners, get result() { return result; }, render, + renderActivityExpected: (nextActivityExpected) => + render(currentThreadId, nextActivityExpected), remount, statuses, streams, @@ -140,351 +205,195 @@ function createHarness({ }; } -test("useSSE reflects browser offline and online events", () => { - const { cleanup, context, statuses, streams, windowListeners } = createHarness(); - +test("useSSE delegates framing, credentials, and retries to EventSourcePlus", () => { + const { streams, statuses } = createHarness(); const stream = streams[0]; - stream.readyState = context.EventSource.OPEN; - stream.onopen(); - windowListeners.get("offline")(); - assert.deepEqual(statuses, ["connecting", "connected", "reconnecting"]); - - windowListeners.get("online")(); - assert.deepEqual(statuses, [ - "connecting", - "connected", - "reconnecting", - "connected", - ]); - cleanup(); - assert.equal(windowListeners.has("offline"), false); - assert.equal(windowListeners.has("online"), false); -}); - -test("useSSE starts reconnecting when the browser is already offline", () => { - const { statuses } = createHarness({ online: false }); - - assert.deepEqual(statuses, ["reconnecting"]); + assert.equal( + stream.url, + "http://localhost/events/thread-1?connection_id=browser-tab-connection", + ); + assert.deepEqual(stream.options.headers(), { Authorization: "Bearer token-1" }); + assert.equal(new URL(stream.url).searchParams.has("token"), false); + assert.equal(stream.options.credentials, "same-origin"); + assert.equal(stream.options.retryStrategy, "always"); + assert.equal(stream.options.maxRetryInterval, 30_000); + assert.equal(typeof stream.requestOptions.query.connection_generation, "number"); + + stream.respond(); + assert.deepEqual(statuses, ["connecting", "connecting", "connected"]); + + stream.hooks.onRequestError?.({}); + assert.equal(statuses.at(-1), "reconnecting"); }); -test("useSSE resumes delivery after returning to a hidden tab", () => { +test("useSSE dispatches typed frames from the packaged parser", () => { const events = []; - const { context, documentListeners, statuses, streams, timers } = createHarness({ - onEvent: (event) => events.push(event), - }); - - const initial = streams[0]; - initial.readyState = context.EventSource.OPEN; - initial.onopen(); + const { streams } = createHarness({ onEvent: (event) => events.push(event) }); + const stream = streams[0]; - context.document.visibilityState = "hidden"; - documentListeners.get("visibilitychange")(); - assert.equal(initial.closeCalls, 1); + stream.message( + { type: "projection_update", state: { items: [] } }, + { id: "cursor-1" }, + ); + stream.message( + { state: { items: [] } }, + { event: "projection_snapshot", id: "cursor-2" }, + ); - context.document.visibilityState = "visible"; - documentListeners.get("visibilitychange")(); - const resumed = streams[1]; - resumed.readyState = context.EventSource.CONNECTING; - resumed.onerror({}); + assert.equal( + JSON.stringify(events), + JSON.stringify([ + { + type: "projection_update", + frame: { type: "projection_update", state: { items: [] } }, + lastEventId: "cursor-1", + }, + { + type: "projection_snapshot", + frame: { state: { items: [] } }, + lastEventId: "cursor-2", + }, + ]), + ); +}); - assert.equal(resumed.closeCalls, 0); - assert.equal(timers.filter((timer) => timer.delay === 10_000).length, 1); +test("useSSE reconnects an active run when an open stream stops delivering", () => { + const { renderActivityExpected, streams, timers } = createHarness(); + const stream = streams[0]; + stream.respond(); - resumed.readyState = context.EventSource.OPEN; - resumed.listener("projection_update")({ - data: JSON.stringify({ type: "projection_update", state: { items: [] } }), - lastEventId: "after-tab-resume", - }); + renderActivityExpected(true); + const watchdog = timers.find( + (timer) => timer.delay === 30_000 && !timer.cleared, + ); + assert.ok(watchdog); - assert.equal(streams.length, 2); - assert.equal(events.length, 1); - assert.deepEqual(statuses, [ - "connecting", - "connected", - "paused", - "connecting", - "reconnecting", - "connected", - ]); + watchdog.handler(); + assert.equal(stream.controller.reconnectCalls, 1); + assert.equal(typeof stream.requestOptions.query.connection_generation, "number"); }); -test("useSSE lets EventSource recover transient failures natively", () => { - const { context, statuses, streams, timers } = createHarness(); - - assert.deepEqual(statuses, ["connecting"]); - const stream = streams[0]; - stream.readyState = context.EventSource.CONNECTING; - stream.onerror({}); - - assert.deepEqual(statuses, ["connecting", "reconnecting"]); - assert.equal(stream.closeCalls, 0); - assert.equal(timers.length, 1); - assert.equal(timers[0].delay, 10_000); - - stream.readyState = context.EventSource.OPEN; - stream.listener("keep_alive")({ - data: JSON.stringify({ type: "keep_alive" }), - lastEventId: "resume-cursor", - }); +test("useSSE arms the watchdog when an already-active stream opens", () => { + const { streams, timers } = createHarness({ activityExpected: true }); + streams[0].respond(); - assert.equal(timers[0].cleared, true); - assert.equal(streams.length, 1); - assert.deepEqual(statuses, ["connecting", "reconnecting", "connected"]); + assert.ok( + timers.some((timer) => timer.delay === 30_000 && !timer.cleared), + "an active run must be watched even when no activity transition occurs after connect", + ); }); -test("useSSE keeps one watchdog across repeated native errors", () => { - const { context, statuses, streams, timers } = createHarness(); +test("useSSE rejects a successful response that is not an event stream", () => { + const { statuses, streams } = createHarness(); + streams[0].respond(200, "text/html"); - const stream = streams[0]; - stream.readyState = context.EventSource.CONNECTING; - stream.onerror({}); - stream.onerror({}); - - assert.equal(timers.length, 1); - assert.equal(timers[0].delay, 10_000); - assert.equal(stream.closeCalls, 0); - assert.deepEqual(statuses, ["connecting", "reconnecting", "reconnecting"]); + assert.notEqual(statuses.at(-1), CONNECTION_STATUS.CONNECTED); }); -test("useSSE falls back to app reconnect timer for closed streams", () => { - const { context, statuses, streams, timers } = createHarness(); - +test("useSSE pauses while hidden and reconnects when visible", () => { + const { context, documentListeners, statuses, streams } = createHarness(); const stream = streams[0]; - stream.readyState = context.EventSource.CLOSED; - stream.onerror({}); - - assert.deepEqual(statuses, ["connecting", "disconnected"]); - assert.equal(stream.closeCalls, 1); - assert.equal(timers.length, 1); - assert.equal(timers[0].delay, 2000); + stream.respond(); - timers[0].handler(); + context.document.visibilityState = "hidden"; + documentListeners.get("visibilitychange")(); + assert.deepEqual(stream.controller.abortCalls, ["document hidden"]); + assert.equal(statuses.at(-1), "paused"); - assert.equal(streams.length, 2); - assert.deepEqual(statuses, ["connecting", "disconnected", "reconnecting"]); + context.document.visibilityState = "visible"; + documentListeners.get("visibilitychange")(); + assert.equal(stream.controller.reconnectCalls, 1); + assert.equal(statuses.at(-2), "connecting"); + assert.equal(statuses.at(-1), "reconnecting"); }); -test("useSSE replaces a reconnect attempt that never finishes opening", () => { - const { context, statuses, streams, timers } = createHarness(); - - const first = streams[0]; - first.readyState = context.EventSource.CONNECTING; - first.onerror({}); - - const nativeWatchdog = timers.find((timer) => timer.delay === 10_000); - assert.ok(nativeWatchdog); - nativeWatchdog.handler(); - - const reconnectTimer = timers.find((timer) => timer.delay === 2000); - assert.ok(reconnectTimer); - reconnectTimer.handler(); - - assert.equal(streams.length, 2); - const stalledReplacement = streams[1]; - const openWatchdog = timers - .filter((timer) => timer.delay === 10_000 && !timer.cleared) - .at(-1); - assert.ok(openWatchdog); - - openWatchdog.handler(); - - assert.equal(stalledReplacement.closeCalls, 1); - assert.equal( - timers.filter((timer) => timer.delay === 4000 && !timer.cleared).length, - 1, - ); - assert.deepEqual(statuses, [ - "connecting", - "reconnecting", - "reconnecting", - "reconnecting", - "reconnecting", +test("useSSE lets retryable responses retry and stops on terminal responses", () => { + const retryable = createHarness(); + retryable.streams[0].respond(204, ""); + assert.equal(retryable.statuses.at(-1), "reconnecting"); + assert.equal(retryable.streams[0].controller.abortCalls.length, 0); + retryable.cleanup(); + + const terminal = createHarness(); + terminal.streams[0].respond(403, "application/json"); + assert.equal(terminal.statuses.at(-1), "disconnected"); + assert.deepEqual(terminal.streams[0].controller.abortCalls, [ + "non-retryable stream response", ]); + terminal.cleanup(); }); -test("useSSE does not reconnect after a non-retryable server error", () => { +test("useSSE stops after a non-retryable stream event", () => { const events = []; - const { context, statuses, streams, timers, windowListeners } = createHarness({ + const { statuses, streams } = createHarness({ onEvent: (event) => events.push(event), }); - const stream = streams[0]; - stream.listener("stream_error")({ - data: JSON.stringify({ - type: "stream_error", - error: "not_found", - kind: "not_found", - retryable: false, - }), - lastEventId: "", - }); - - assert.equal(stream.closeCalls, 1); - assert.equal(stream.readyState, context.EventSource.CLOSED); - assert.equal(timers.length, 0); - assert.deepEqual(statuses, ["connecting", "disconnected"]); - assert.equal(events.length, 1); - assert.equal(events[0].type, "error"); - assert.equal(events[0].frame.retryable, false); - - // Browsers report the server's subsequent close through `onerror`. It must - // not override the terminal classification or schedule another connection. - stream.onerror({}); - windowListeners.get("online")(); - assert.equal(timers.length, 0); - assert.equal(streams.length, 1); - assert.deepEqual(statuses, ["connecting", "disconnected"]); -}); - -test("useSSE starts each thread route from a fresh projection", () => { - const { cleanup, render, streams } = createHarness(); - streams[0].listener("projection_update")({ - data: JSON.stringify({ - type: "projection_update", - state: { items: [] }, - }), - lastEventId: "thread-1-cursor", - }); - - render("thread-2"); - assert.equal(streams[1].args.threadId, "thread-2"); - assert.equal(streams[1].args.afterCursor, undefined); - assert.equal(streams[1].args.connectionId, "browser-tab-connection"); - assert.equal(streams[1].args.connectionGeneration, 2); - - streams[1].listener("projection_update")({ - data: JSON.stringify({ - type: "projection_update", - state: { items: [] }, - }), - lastEventId: "thread-2-cursor", - }); - - render("thread-1"); - assert.equal(streams[2].args.threadId, "thread-1"); - assert.equal(streams[2].args.afterCursor, undefined); - assert.equal(streams[2].args.connectionGeneration, 3); - assert.equal( - streams[2].args.connectionId, - streams[1].args.connectionId, - "thread switches must reuse the tab connection id so the server can supersede the old stream", - ); - - render("thread-2"); - assert.equal(streams[3].args.threadId, "thread-2"); - assert.equal(streams[3].args.afterCursor, undefined); - assert.equal(streams[3].args.connectionGeneration, 4); - - cleanup(); -}); - -test("useSSE discards a volatile live cursor when the chat page remounts", () => { - const { cleanup, remount, streams } = createHarness(); - - streams[0].listener("projection_update")({ - data: JSON.stringify({ - type: "projection_update", - state: { items: [] }, - }), - lastEventId: "before-navigation", + stream.message({ + type: "stream_error", + kind: "not_found", + retryable: false, }); - remount("thread-1"); - assert.equal(streams[1].args.afterCursor, undefined); - assert.equal(streams[1].args.connectionGeneration, 2); - - cleanup(); -}); - -test("useSSE ignores callbacks from a stream disposed by a thread switch", () => { - const { cleanup, render, statuses, streams, timers } = createHarness(); - const disposedStream = streams[0]; - - render("thread-2"); - assert.equal(disposedStream.closeCalls, 1); - assert.equal(streams.length, 2); - - // A network error can already be queued when React cleans up the old - // effect. It must not schedule an orphan reconnect for the old thread. - disposedStream.onerror({}); - disposedStream.listener("stream_error")({ - data: JSON.stringify({ - error: "unavailable", - kind: "replay_unavailable", - retryable: true, - }), - lastEventId: "", - }); - - assert.equal(timers.length, 0); - assert.equal(streams.length, 2); - assert.deepEqual(statuses, ["connecting", "connecting"]); - - cleanup(); + assert.equal(events[0].type, "error"); + assert.equal(statuses.at(-1), "disconnected"); + assert.deepEqual(stream.controller.abortCalls, [ + "non-retryable stream event", + ]); }); -test("useSSE rebases from origin when the server rejects its replay cursor", () => { +test("useSSE clears packaged replay state before rebasing from origin", () => { const events = []; - const { statuses, streams, timers } = createHarness({ + const { statuses, streams } = createHarness({ onEvent: (event) => events.push(event), }); - const stream = streams[0]; - stream.listener("projection_update")({ - data: JSON.stringify({ - type: "projection_update", - state: { items: [] }, - }), - lastEventId: "stale-cursor", - }); - stream.listener("stream_error")({ - data: JSON.stringify({ - error: "unavailable", - kind: "replay_unavailable", - retryable: true, - }), - lastEventId: "", + stream.message( + { type: "projection_update", state: { items: [] } }, + { id: "stale-cursor" }, + ); + assert.equal(stream.lastEventId, "stale-cursor"); + + stream.message({ + type: "stream_error", + kind: "replay_unavailable", + retryable: true, }); - assert.equal(stream.closeCalls, 1); - assert.deepEqual(statuses, ["connecting", "connected", "reconnecting"]); - assert.equal(timers.length, 1); - assert.equal(timers[0].delay, 2000); - assert.equal(events.length, 2); - assert.equal(events[1].type, "error"); - - timers[0].handler(); - - assert.equal(streams.length, 2); - assert.equal(streams[1].args.afterCursor, undefined); - assert.deepEqual(statuses, [ - "connecting", - "connected", - "reconnecting", - "reconnecting", - ]); + assert.equal(stream.lastEventId, undefined); + assert.equal(stream.controller.reconnectCalls, 1); + assert.equal(statuses.at(-1), "reconnecting"); + assert.equal(events.at(-1).type, "error"); }); -test("useSSE does not treat a legacy application error as a transport error", () => { +test("useSSE starts each route from a fresh stream and ignores disposed callbacks", () => { const events = []; - const { statuses, streams, timers } = createHarness({ + const { cleanup, render, streams } = createHarness({ onEvent: (event) => events.push(event), }); + const first = streams[0]; + first.message( + { type: "projection_update", state: { items: [] } }, + { id: "thread-1-cursor" }, + ); - streams[0].onerror({ - data: JSON.stringify({ - error: "unavailable", - kind: "replay_unavailable", - retryable: true, - }), - lastEventId: "", - }); + render("thread-2"); + const second = streams[1]; + assert.equal(first.controller.abortCalls.at(-1), "component disposed"); + assert.equal(second.lastEventId, undefined); + assert.match(second.url, /thread-2/); + assert.equal( + new URL(first.url).searchParams.get("connection_id"), + new URL(second.url).searchParams.get("connection_id"), + ); + assert.ok( + second.requestOptions.query.connection_generation > + first.requestOptions.query.connection_generation, + ); + first.message({ type: "projection_update", state: { items: ["late"] } }); assert.equal(events.length, 1); - assert.equal(events[0].type, "error"); - assert.deepEqual(statuses, ["connecting", "reconnecting"]); - assert.equal(timers.length, 1); - assert.equal(timers[0].delay, 2000); + cleanup(); }); diff --git a/crates/ironclaw_webui/frontend/src/styles/app.css b/crates/ironclaw_webui/frontend/src/styles/app.css index ac4fb3f441..96574f1cd3 100644 --- a/crates/ironclaw_webui/frontend/src/styles/app.css +++ b/crates/ironclaw_webui/frontend/src/styles/app.css @@ -1,5 +1,6 @@ @import "tailwindcss"; @source "../"; +@source "../../node_modules/streamdown/dist/*.js"; @theme { --font-sans: "Geist", "Inter", "SF Pro Display", "Helvetica Neue", diff --git a/crates/ironclaw_webui/src/webui_v2/handlers.rs b/crates/ironclaw_webui/src/webui_v2/handlers.rs index 7f99a857e6..ebbf2fc2d8 100644 --- a/crates/ironclaw_webui/src/webui_v2/handlers.rs +++ b/crates/ironclaw_webui/src/webui_v2/handlers.rs @@ -113,7 +113,7 @@ use uuid::Uuid; use crate::webui_v2::error::WebUiV2HttpError; use crate::webui_v2::router::{WebUiV2Capabilities, WebUiV2State}; -use crate::webui_v2::schema::WebChatV2EventFrame; +use crate::webui_v2::schema::{WebChatV2Event, WebChatV2EventFrame}; use crate::webui_v2::sse_capacity::{SSE_MAX_LIFETIME, SseSlot}; // Session bootstrap must stay cheap and non-blocking: this flag only tunes @@ -1107,9 +1107,8 @@ pub async fn get_attachment( Ok((StatusCode::OK, headers, attachment.bytes).into_response()) } -/// SSE polling cadence for `stream_events`. The service only exposes a -/// drain-style read; once the backlog is flushed the handler waits this -/// long before checking for newly arrived events. +/// SSE polling cadence for product surfaces that expose only the legacy +/// drain-style read. Subscription-capable surfaces bypass this fallback. const SSE_POLL_INTERVAL: Duration = Duration::from_secs(1); /// Upper bound for idle `stream_events` polling. A browser tab with no @@ -1118,8 +1117,8 @@ const SSE_POLL_INTERVAL: Duration = Duration::from_secs(1); /// hosted Postgres. const SSE_IDLE_POLL_MAX_INTERVAL: Duration = Duration::from_secs(3); -/// SSE keep-alive cadence. axum emits an SSE comment line every interval -/// to keep proxies from closing the idle connection. +/// SSE keep-alive cadence. Axum emits a comment line for proxies, and the +/// subscription path emits a typed frame for browser application code. const SSE_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(15); /// HTTP header the browser's `EventSource` sends on auto-reconnect to @@ -1304,6 +1303,12 @@ fn sse_error_event(error: ProductSurfaceError) -> Event { } } +fn sse_keep_alive_event() -> Event { + Event::default() + .event(WebChatV2Event::KeepAlive.event_name()) + .data(r#"{"type":"keep_alive"}"#) +} + fn build_sse_stream( services: std::sync::Arc, caller: ProductSurfaceCaller, @@ -1359,7 +1364,8 @@ fn build_sse_stream( ); return; } - Ok(Ok(response)) => { + Ok(Ok(mut response)) => { + let subscription = response.subscription.take(); let events = match decode_product_outbound_events(response.events) { Ok(events) => events, Err(error) => { @@ -1376,11 +1382,63 @@ fn build_sse_stream( yield Ok(event); } } + if let Some(subscription) = subscription { + loop { + let remaining = + SSE_MAX_LIFETIME.saturating_sub(started_at.elapsed()); + if remaining.is_zero() { + return; + } + let next = tokio::select! { + biased; + _ = slot_guard.cancelled() => return, + result = tokio::time::timeout( + remaining, + subscription.next(), + ) => result, + _ = tokio::time::sleep(SSE_KEEPALIVE_INTERVAL) => { + // Axum's comment keep-alive keeps proxies + // open, but parser packages do not surface + // comments to the browser watchdog. Emit a + // typed application frame as liveness proof + // while the projection is legitimately idle. + yield Ok(sse_keep_alive_event()); + continue; + } + }; + let response = match next { + Ok(Some(Ok(response))) => response, + Ok(Some(Err(error))) => { + yield Ok(sse_error_event(error)); + return; + } + Ok(None) => return, + Err(_) => { + tracing::debug!( + target = "ironclaw_webui_v2::sse", + "stream_events subscription pending past SSE_MAX_LIFETIME; closing stream" + ); + return; + } + }; + let events = match decode_product_outbound_events(response.events) { + Ok(events) => events, + Err(error) => { + yield Ok(sse_error_event(error)); + return; + } + }; + for envelope in events { + if let Some(event) = webchat_sse_event_from_envelope(envelope) { + yield Ok(event); + } + } + } + } if had_events { - // The production projection service waits on its live - // subscription when no new item is replayable. Re-enter - // it immediately after delivering a batch so assistant - // text deltas are not delayed by the idle poll cadence. + // Drain-only compatibility surfaces may have another + // buffered batch ready. Re-enter immediately after + // delivery before applying the idle poll cadence. idle_polls = 0; continue; } diff --git a/crates/ironclaw_webui/tests/support/harness.rs b/crates/ironclaw_webui/tests/support/harness.rs index 115c112d89..0531744601 100644 --- a/crates/ironclaw_webui/tests/support/harness.rs +++ b/crates/ironclaw_webui/tests/support/harness.rs @@ -110,6 +110,7 @@ impl ProductSurface for StubServices { Ok(ironclaw_host_api::ProductSurfaceStreamResponse { events: Vec::new(), next_cursor: None, + subscription: None, }) } diff --git a/crates/ironclaw_webui/tests/support/product_surface.rs b/crates/ironclaw_webui/tests/support/product_surface.rs index 6317194dc2..78662206a5 100644 --- a/crates/ironclaw_webui/tests/support/product_surface.rs +++ b/crates/ironclaw_webui/tests/support/product_surface.rs @@ -227,6 +227,7 @@ impl ProductSurface for ProgrammableProductSurface { Ok(ironclaw_host_api::ProductSurfaceStreamResponse { events, next_cursor: None, + subscription: None, }) } } diff --git a/crates/ironclaw_webui/tests/webui_v2_handlers_contract.rs b/crates/ironclaw_webui/tests/webui_v2_handlers_contract.rs index 3b10dba542..d531ae633b 100644 --- a/crates/ironclaw_webui/tests/webui_v2_handlers_contract.rs +++ b/crates/ironclaw_webui/tests/webui_v2_handlers_contract.rs @@ -14,7 +14,10 @@ mod programmable_surface; use std::collections::VecDeque; -use std::sync::{Arc, Mutex}; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, +}; use std::time::Duration; use async_trait::async_trait; @@ -28,8 +31,9 @@ use ironclaw_host_api::{ ActivityId, AgentId, Blocked, CapabilityId, ExtensionId, GateRef, GateWaypoint, InvocationId, LifecyclePublicState, Outcome, OutcomeRefs, ProductSurface, ProductSurfaceCaller, ProductSurfaceError, ProductSurfaceErrorCode, ProductSurfaceErrorKind, - ProductSurfaceValidationCode, ProjectId, Resolution, ResultPreviewMeta, ResultProgress, - ResultRef, RuntimeKind, SafeSummary, TenantId, TerminateHint, ThreadId, ToolVerdict, UserId, + ProductSurfaceEventSubscription, ProductSurfaceStreamResponse, ProductSurfaceValidationCode, + ProjectId, Resolution, ResultPreviewMeta, ResultProgress, ResultRef, RuntimeKind, SafeSummary, + TenantId, TerminateHint, ThreadId, ToolVerdict, UserId, }; use ironclaw_product::{ ADMIN_USER_DELETE_CAPABILITY_ID, ADMIN_USER_PUT_SECRET_CAPABILITY_ID, ADMIN_USER_SECRETS_VIEW, @@ -114,7 +118,7 @@ use ironclaw_webui::webui_v2::{ }; use serde::Serialize; use serde_json::Value; -use tokio::sync::Notify; +use tokio::sync::{Notify, mpsc}; use tower::ServiceExt; use programmable_surface::ProgrammableProductSurface; @@ -462,6 +466,9 @@ struct StubServices { read_attachment_calls: Mutex>, read_attachment_response: Mutex>, stream_events_calls: Mutex>, + subscribe_events_calls: Mutex>, + event_subscription_enabled: AtomicBool, + hold_event_subscription_open: AtomicBool, cancel_run_calls: Mutex>, resolve_gate_calls: Mutex>, retry_run_calls: Mutex>, @@ -562,6 +569,16 @@ impl StubServices { .push_back(response); } + fn enable_event_subscription(&self) { + self.event_subscription_enabled + .store(true, Ordering::Release); + } + + fn hold_event_subscription_open(&self) { + self.hold_event_subscription_open + .store(true, Ordering::Release); + } + /// Triggered the first time `stream_events` is invoked. Lets the SSE /// test wait on the actual service call rather than guessing at a /// timeout — axum's SSE body is lazy, so the handler does not run @@ -1737,24 +1754,61 @@ impl ProductSurface for StubServices { .map(ProjectionCursor::new) .transpose() .map_err(ProductSurfaceError::internal_from)?; - let response = StubServices::stream_events( - self, - caller, - RebornStreamEventsRequest { - thread_id, - after_cursor, - }, - ) - .await?; + let stream_request = RebornStreamEventsRequest { + thread_id, + after_cursor, + }; + let response = StubServices::stream_events(self, caller, stream_request.clone()).await?; let events = response .events .into_iter() .map(serde_json::to_value) .collect::, _>>() .map_err(ProductSurfaceError::internal_from)?; + let subscription = if self.event_subscription_enabled.load(Ordering::Acquire) { + self.subscribe_events_calls + .lock() + .expect("lock") + .push(stream_request); + let responses = self + .next_stream_events + .lock() + .expect("lock") + .drain(..) + .collect::>(); + let hold_open = self.hold_event_subscription_open.load(Ordering::Acquire); + let (sender, receiver) = mpsc::channel(1); + tokio::spawn(async move { + for response in responses { + let response = response.and_then(|response| { + let events = response + .events + .into_iter() + .map(serde_json::to_value) + .collect::, _>>() + .map_err(ProductSurfaceError::internal_from)?; + Ok(ProductSurfaceStreamResponse { + events, + next_cursor: None, + subscription: None, + }) + }); + if sender.send(response).await.is_err() { + return; + } + } + if hold_open { + std::future::pending::<()>().await; + } + }); + Some(ProductSurfaceEventSubscription::new(receiver)) + } else { + None + }; Ok(ironclaw_host_api::ProductSurfaceStreamResponse { events, next_cursor: None, + subscription, }) } } @@ -6662,6 +6716,85 @@ async fn stream_events_continues_immediately_after_non_empty_batch() { ); } +#[tokio::test] +async fn stream_events_forwards_one_continuous_subscription_without_poll_gaps() { + let services = Arc::new(StubServices::default()); + services.enable_event_subscription(); + services.enqueue_stream_events(Ok(RebornStreamEventsResponse { + events: vec![make_projection_update_envelope("cursor:sub-a")], + })); + services.enqueue_stream_events(Ok(RebornStreamEventsResponse { + events: vec![make_projection_update_envelope("cursor:sub-b")], + })); + + let response = router_with(services.clone()) + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/api/webchat/v2/threads/thread-x/events") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("oneshot"); + let mut body = response.into_body(); + let bytes = collect_sse_until(&mut body, Duration::from_millis(250), |buf| { + parse_sse_events(buf).len() >= 2 + }) + .await; + drop(body); + + assert_eq!( + parse_sse_events(&bytes).len(), + 2, + "both back-to-back subscription events must reach one SSE connection" + ); + assert_eq!( + services.subscribe_events_calls.lock().expect("lock").len(), + 1, + "the SSE connection must open exactly one product subscription" + ); + assert_eq!( + services.stream_events_calls.lock().expect("lock").len(), + 1, + "the SSE connection must enter the product stream method only once" + ); +} + +#[tokio::test(start_paused = true)] +async fn stream_events_emits_application_keep_alive_while_subscription_is_idle() { + let services = Arc::new(StubServices::default()); + services.enable_event_subscription(); + services.hold_event_subscription_open(); + + let response = router_with(services) + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/api/webchat/v2/threads/thread-x/events") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("oneshot"); + let mut body = response.into_body(); + let body_task = tokio::spawn(async move { + collect_sse_until(&mut body, Duration::from_secs(30), |buffer| { + String::from_utf8_lossy(buffer).contains("event: keep_alive") + }) + .await + }); + tokio::task::yield_now().await; + tokio::task::yield_now().await; + tokio::time::advance(Duration::from_secs(15)).await; + tokio::task::yield_now().await; + + let bytes = body_task.await.expect("SSE body task"); + let text = String::from_utf8_lossy(&bytes); + assert!(text.contains("event: keep_alive"), "{text}"); + assert!(text.contains(r#"data: {"type":"keep_alive"}"#), "{text}"); +} + // Pins the *wire* contract the browser sees, not just the handler being // called: each envelope must emit a typed WebChat v2 event with the // JSON-serialized projection cursor as the SSE `id` and the redacted diff --git a/scripts/ci/reborn_changed_coverage.py b/scripts/ci/reborn_changed_coverage.py index 5ce0d7e1dc..a9e596e60d 100755 --- a/scripts/ci/reborn_changed_coverage.py +++ b/scripts/ci/reborn_changed_coverage.py @@ -69,6 +69,14 @@ def _is_exempt( return len(parts) > 1 and parts[1] in exempt_crates +def _is_product_source(path: str) -> bool: + return ( + PRODUCT_SOURCE_RE.fullmatch(path) is not None + and "/tests/" not in path + and not path.endswith("/tests.rs") + ) + + def evaluate( diff_text: str, lcov_text: str, @@ -88,7 +96,7 @@ def evaluate( changed = { path: lines for path, lines in _changed_lines(diff_text).items() - if PRODUCT_SOURCE_RE.fullmatch(path) + if _is_product_source(path) and not _is_exempt(path, module_exemptions, crate_exemptions) } coverage = _lcov_lines(lcov_text) diff --git a/scripts/ci/test_reborn_changed_coverage.py b/scripts/ci/test_reborn_changed_coverage.py index 17d05dcf65..6eda8f4fcc 100755 --- a/scripts/ci/test_reborn_changed_coverage.py +++ b/scripts/ci/test_reborn_changed_coverage.py @@ -69,6 +69,19 @@ def test_test_sources_are_not_counted_as_changed_product_code(self): self.assertTrue(report["passed"]) self.assertEqual(report["changed_product_files"], []) + def test_src_test_modules_are_not_counted_as_changed_product_code(self): + paths = [ + "crates/ironclaw_example/src/tests.rs", + "crates/ironclaw_example/src/projection/tests/contract.rs", + ] + + for path in paths: + with self.subTest(path=path): + report = evaluate(_diff(path, 1, 2), "", 90.0) + + self.assertTrue(report["passed"]) + self.assertEqual(report["changed_product_files"], []) + def test_owned_exemption_removes_file_from_changed_code_gate(self): path = "crates/ironclaw_example/src/generated.rs" @@ -94,6 +107,9 @@ def test_reborn_workflow_runs_self_test_and_pr_gate(self): self.assertIn("python3 scripts/ci/test_reborn_changed_coverage.py", workflow) self.assertIn("python3 scripts/ci/reborn_changed_coverage.py", workflow) self.assertIn("github.event.pull_request.base.sha", workflow) + self.assertIn("github.event.pull_request.head.sha", workflow) + self.assertIn('--head "$HEAD_SHA"', workflow) + self.assertNotIn('--head "$(git rev-parse HEAD)"', workflow) self.assertIn("reborn-changed-coverage.json", workflow) self.assertRegex( workflow, diff --git a/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py b/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py index fbe7fd0609..d9c65a4928 100644 --- a/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py +++ b/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py @@ -204,57 +204,130 @@ async def _wait_for_automation_named( ) from None -async def _install_fake_v2_event_source(page) -> None: - await page.add_init_script( - """ +async def _install_fake_v2_event_stream(page) -> None: + script = """ (() => { + const nativeFetch = window.fetch.bind(window); + const encoder = new TextEncoder(); + const expectedAuthorization = __EXPECTED_AUTHORIZATION__; let activeStream = null; + let holdNextConnection = false; + const currentStream = () => { - if (!activeStream || activeStream.readyState === 2) { - throw new Error("no EventSource stream is open"); + if (!activeStream || activeStream.closed) { + throw new Error("no event stream is open"); } return activeStream; }; - class FakeEventSource extends EventTarget { - constructor(url) { - super(); - this.url = url; - this.readyState = 0; - if (activeStream && activeStream.readyState !== 2) { - activeStream.close(); + + const closeStream = (stream, error = null) => { + if (!stream || stream.closed) return; + stream.closed = true; + if (stream.controller) { + if (error) { + stream.controller.error(error); + } else { + stream.controller.close(); } - activeStream = this; - setTimeout(() => { - if (activeStream !== this || this.readyState === 2) return; - this.readyState = 1; - if (typeof this.onopen === "function") this.onopen(new Event("open")); - }, 0); } - close() { - this.readyState = 2; - if (activeStream === this) activeStream = null; + if (activeStream === stream) activeStream = null; + }; + + const openStreamResponse = (signal) => { + const stream = { closed: false, controller: null }; + const body = new ReadableStream({ + start(controller) { + stream.controller = controller; + }, + cancel() { + stream.closed = true; + if (activeStream === stream) activeStream = null; + }, + }); + if (activeStream && !activeStream.closed) { + closeStream(activeStream); + } + activeStream = stream; + signal?.addEventListener( + "abort", + () => closeStream(stream), + { once: true }, + ); + return new Response(body, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }; + + window.fetch = async (input, init = {}) => { + const request = new Request(input, init); + const url = new URL(request.url, window.location.href); + if (!url.pathname.endsWith("/events")) { + return nativeFetch(input, init); } - } - window.EventSource = FakeEventSource; + if (url.searchParams.has("token")) { + return new Response("", { status: 400 }); + } + if (request.headers.get("Authorization") !== expectedAuthorization) { + return new Response("", { status: 401 }); + } + if (!holdNextConnection) { + return openStreamResponse(request.signal); + } + return new Promise((resolve, reject) => { + const stream = { + closed: false, + controller: null, + resolve, + reject, + }; + activeStream = stream; + request.signal?.addEventListener( + "abort", + () => { + if (stream.closed) return; + stream.closed = true; + if (activeStream === stream) activeStream = null; + reject(new DOMException("Aborted", "AbortError")); + }, + { once: true }, + ); + }); + }; + window.__emitV2Sse = (type, frame, id = crypto.randomUUID()) => { const stream = currentStream(); - const event = new MessageEvent(type, { - data: JSON.stringify({ type, ...frame }), - lastEventId: id, - }); - stream.dispatchEvent(event); + if (!stream.controller) throw new Error("event stream is reconnecting"); + stream.controller.enqueue(encoder.encode( + `id: ${id}\\nevent: ${type}\\ndata: ${ + JSON.stringify({ type, ...frame }) + }\\n\\n` + )); }; + window.__failLatestV2Sse = (readyState = 2) => { const stream = currentStream(); - stream.readyState = readyState; - if (readyState === 2 && activeStream === stream) activeStream = null; - if (typeof stream.onerror !== "function") { - throw new Error("EventSource has no error handler"); + if (readyState === 0) { + holdNextConnection = true; + closeStream(stream, new TypeError("event stream interrupted")); + return; } - stream.onerror(new Event("error")); + holdNextConnection = false; + if (stream.resolve) { + stream.closed = true; + if (activeStream === stream) activeStream = null; + stream.resolve(new Response("", { status: 401 })); + return; + } + closeStream(stream, new TypeError("event stream interrupted")); }; })(); """ + await page.add_init_script( + script.replace( + "__EXPECTED_AUTHORIZATION__", + json.dumps(f"Bearer {REBORN_V2_AUTH_TOKEN}"), + ) ) @@ -1512,7 +1585,7 @@ async def test_reborn_v2_disconnected_run_shows_status_and_stops_typing( thread_id = "thread-disconnected-run" context = await reborn_v2_browser.new_context(viewport={"width": 1280, "height": 720}) page = await context.new_page() - await _install_fake_v2_event_source(page) + await _install_fake_v2_event_stream(page) async def fulfill_json(route, body, status=200) -> None: await route.fulfill( @@ -1638,7 +1711,7 @@ async def test_reborn_v2_approval_gate_blocks_composer_send( send_requests: list[dict] = [] context = await reborn_v2_browser.new_context(viewport={"width": 1280, "height": 720}) page = await context.new_page() - await _install_fake_v2_event_source(page) + await _install_fake_v2_event_stream(page) async def fulfill_json(route, body, status=200) -> None: await route.fulfill( @@ -1766,7 +1839,7 @@ async def test_reborn_v2_unscoped_activity_stays_with_previous_reply( release_second_send = asyncio.Event() context = await reborn_v2_browser.new_context(viewport={"width": 1280, "height": 720}) page = await context.new_page() - await _install_fake_v2_event_source(page) + await _install_fake_v2_event_stream(page) async def fulfill_json(route, body, status=200) -> None: await route.fulfill( @@ -2457,7 +2530,7 @@ async def test_reborn_v2_loading_older_messages_preserves_viewport( viewport={"width": 1280, "height": 720} ) page = await context.new_page() - await _install_fake_v2_event_source(page) + await _install_fake_v2_event_stream(page) async def fulfill_json(route, body) -> None: await route.fulfill( diff --git a/tests/integration/webui_v2_product_api.rs b/tests/integration/webui_v2_product_api.rs index 53b239de07..080f926362 100644 --- a/tests/integration/webui_v2_product_api.rs +++ b/tests/integration/webui_v2_product_api.rs @@ -1903,12 +1903,10 @@ async fn sse_activity_stream_replay_and_reconnect() { /// wires into `DefaultProductSurface`) and the production event-stream /// recipe `sse_activity_stream_replay_and_reconnect` above already pins. /// -/// "Refresh" is simulated the same way that precedent does: a fresh -/// `stream_events` drain with `after_cursor: None` — the SSE handler is a -/// polling wrapper over the same drain (W5-WEBUI-SPIKE), so this is -/// behaviorally equivalent to a browser opening a brand new `EventSource` -/// after a cold reload, without the fragility of reading a chunked HTTP body -/// through `tower::ServiceExt::oneshot`. +/// "Refresh" is simulated by opening a fresh continuous event subscription +/// with `after_cursor: None`, then consuming that same subscription until the +/// pending gate is replayed. This matches the SSE handler without the +/// fragility of reading a chunked HTTP body through `tower::ServiceExt::oneshot`. #[tokio::test] async fn approval_gate_rediscovered_and_resolved_after_refresh() { let group = RebornIntegrationGroup::live_approvals() @@ -1969,23 +1967,29 @@ async fn approval_gate_rediscovered_and_resolved_after_refresh() { let caller = webui_caller_for(&h.binding); let thread_id = h.binding.thread_id.as_str().to_string(); - // --- simulate a cold browser refresh: first drain starts without a cursor, - // then follows the cursor exactly like the SSE handler's polling wrapper. --- + // --- simulate a cold browser refresh: open one continuous subscription + // without a cursor, exactly like a new browser EventSource connection. --- // The hot turn-state cache can expose BlockedApproval just before the // best-effort Blocked lifecycle event reaches the durable projection source. let deadline = tokio::time::Instant::now() + Duration::from_secs(5); - let mut after_cursor = None; + let mut replayed = tokio::time::timeout_at( + deadline, + services.stream_events( + caller.clone(), + ProductSurfaceStreamRequest { + stream_id: Some(thread_id.clone()), + after_cursor: None, + }, + ), + ) + .await + .expect("post-refresh subscription opens before the deadline") + .expect("post-refresh subscription opens"); + let subscription = replayed + .subscription + .take() + .expect("post-refresh stream carries its live continuation"); let gate_prompt = loop { - let replayed = services - .stream_events( - caller.clone(), - ProductSurfaceStreamRequest { - stream_id: Some(thread_id.clone()), - after_cursor: after_cursor.clone(), - }, - ) - .await - .expect("post-refresh drain succeeds"); let events = replayed .events .into_iter() @@ -2000,19 +2004,15 @@ async fn approval_gate_rediscovered_and_resolved_after_refresh() { }) { break prompt; } - if tokio::time::Instant::now() >= deadline { - panic!( - "expected the replayed cold-refresh drain to surface a GatePrompt for {gate_ref:?}: {:?}", - events - ); - } - if let Some(cursor) = events - .last() - .map(|envelope| envelope.projection_cursor.clone()) - { - after_cursor = Some(cursor.as_str().to_string()); - } - tokio::time::sleep(Duration::from_millis(10)).await; + replayed = tokio::time::timeout_at(deadline, subscription.next()) + .await + .unwrap_or_else(|_| { + panic!( + "expected the post-refresh subscription to surface a GatePrompt for {gate_ref:?} before the deadline; last events: {events:?}" + ) + }) + .expect("post-refresh subscription remains open") + .expect("post-refresh subscription event succeeds"); }; assert_eq!( gate_prompt.turn_run_id, run_id,