diff --git a/codex-rs/app-server/BUILD.bazel b/codex-rs/app-server/BUILD.bazel index e5ef8268e..63c5eda00 100644 --- a/codex-rs/app-server/BUILD.bazel +++ b/codex-rs/app-server/BUILD.bazel @@ -2,6 +2,7 @@ load("//:defs.bzl", "codex_rust_crate") codex_rust_crate( name = "app-server", + compile_data = ["src/spine_ui/tree.html"], crate_name = "codex_app_server", extra_binaries = [ "//codex-rs/bwrap:bwrap", diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 7942b7ecf..06c749ad8 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1153,6 +1153,24 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::SpineTreeUpdate(spine_tree_event) => { + if crate::spine_ui::is_enabled() { + let live_spine_ui = { + let mut state = thread_state.lock().await; + state.record_spine_ui_snapshot(spine_tree_event.clone()); + state.live_spine_ui(&event_turn_id).cloned() + }; + if let Some(notification) = live_spine_ui.as_ref().and_then(|spine_ui| { + crate::spine_ui::snapshot_started_notification( + &conversation_id.to_string(), + &event_turn_id, + spine_ui, + ) + }) { + outgoing + .send_server_notification(ServerNotification::ItemStarted(notification)) + .await; + } + } let notification = item_event_to_server_notification( EventMsg::SpineTreeUpdate(spine_tree_event), &conversation_id.to_string(), @@ -1161,6 +1179,24 @@ pub(crate) async fn apply_bespoke_event_handling( outgoing.send_server_notification(notification).await; } EventMsg::SpineSpawnProgress(progress) => { + if crate::spine_ui::is_enabled() { + let live_spine_ui = { + let mut state = thread_state.lock().await; + state.record_spine_ui_spawn_progress(progress.clone()); + state.live_spine_ui(&event_turn_id).cloned() + }; + if let Some(notification) = live_spine_ui.as_ref().and_then(|spine_ui| { + crate::spine_ui::snapshot_started_notification( + &conversation_id.to_string(), + &event_turn_id, + spine_ui, + ) + }) { + outgoing + .send_server_notification(ServerNotification::ItemStarted(notification)) + .await; + } + } let notification = item_event_to_server_notification( EventMsg::SpineSpawnProgress(progress), &conversation_id.to_string(), @@ -1406,7 +1442,7 @@ async fn find_and_remove_turn_summary( thread_state: &Arc>, ) -> TurnSummary { let mut state = thread_state.lock().await; - std::mem::take(&mut state.turn_summary) + state.take_turn_summary() } async fn handle_turn_complete( @@ -1417,6 +1453,12 @@ async fn handle_turn_complete( thread_state: &Arc>, ) { let turn_summary = find_and_remove_turn_summary(conversation_id, thread_state).await; + thread_state + .lock() + .await + .set_spine_ui_terminal_connection_ids(&event_turn_id, outgoing.connection_ids()); + + emit_spine_ui_item_completed(conversation_id, &event_turn_id, &turn_summary, outgoing).await; let (status, error) = match turn_summary.last_error { Some(error) => (TurnStatus::Failed, Some(error)), @@ -1446,6 +1488,12 @@ async fn handle_turn_interrupted( thread_state: &Arc>, ) { let turn_summary = find_and_remove_turn_summary(conversation_id, thread_state).await; + thread_state + .lock() + .await + .set_spine_ui_terminal_connection_ids(&event_turn_id, outgoing.connection_ids()); + + emit_spine_ui_item_completed(conversation_id, &event_turn_id, &turn_summary, outgoing).await; emit_turn_completed_with_status( conversation_id, @@ -1462,6 +1510,30 @@ async fn handle_turn_interrupted( .await; } +async fn emit_spine_ui_item_completed( + conversation_id: ThreadId, + turn_id: &str, + turn_summary: &TurnSummary, + outgoing: &ThreadScopedOutgoingMessageSender, +) { + if !crate::spine_ui::is_enabled() { + return; + } + let Some(spine_ui) = turn_summary.active_spine_ui(turn_id) else { + return; + }; + let Some(notification) = crate::spine_ui::snapshot_completed_notification( + &conversation_id.to_string(), + turn_id, + spine_ui, + ) else { + return; + }; + outgoing + .send_server_notification(ServerNotification::ItemCompleted(notification)) + .await; +} + async fn handle_thread_rollback_failed( _conversation_id: ThreadId, message: String, diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 82d28b537..c5ef329bb 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -111,6 +111,7 @@ mod request_processors; mod request_serialization; mod server_request_error; mod skills_watcher; +mod spine_ui; mod thread_state; mod thread_status; mod transport; diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 32f847e38..51955aab0 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -372,6 +372,7 @@ impl MessageProcessor { let mcp_processor = McpRequestProcessor::new( auth_manager.clone(), Arc::clone(&thread_manager), + thread_state_manager.clone(), outgoing.clone(), config_manager.clone(), ); diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 16d5c2ff9..c9619bc01 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -130,6 +130,10 @@ impl ThreadScopedOutgoingMessageSender { } } + pub(crate) fn connection_ids(&self) -> &[ConnectionId] { + self.connection_ids.as_slice() + } + pub(crate) async fn send_request( &self, payload: ServerRequestPayload, diff --git a/codex-rs/app-server/src/request_processors/mcp_processor.rs b/codex-rs/app-server/src/request_processors/mcp_processor.rs index ac2125c2c..bdc5ad9ec 100644 --- a/codex-rs/app-server/src/request_processors/mcp_processor.rs +++ b/codex-rs/app-server/src/request_processors/mcp_processor.rs @@ -6,6 +6,7 @@ const MCP_TOOL_THREAD_ID_META_KEY: &str = "threadId"; pub(crate) struct McpRequestProcessor { auth_manager: Arc, thread_manager: Arc, + thread_state_manager: ThreadStateManager, outgoing: Arc, config_manager: ConfigManager, } @@ -14,12 +15,14 @@ impl McpRequestProcessor { pub(crate) fn new( auth_manager: Arc, thread_manager: Arc, + thread_state_manager: ThreadStateManager, outgoing: Arc, config_manager: ConfigManager, ) -> Self { Self { auth_manager, thread_manager, + thread_state_manager, outgoing, config_manager, } @@ -338,6 +341,12 @@ impl McpRequestProcessor { ); server_names.sort(); server_names.dedup(); + let inject_spine_ui = crate::spine_ui::is_enabled(); + if inject_spine_ui { + server_names.push(crate::spine_ui::SERVER_NAME.to_string()); + server_names.sort(); + server_names.dedup(); + } let total = server_names.len(); let limit = params.limit.unwrap_or(total as u32).max(1) as usize; @@ -360,17 +369,26 @@ impl McpRequestProcessor { let data: Vec = server_names[start..end] .iter() - .map(|name| McpServerStatus { - name: name.clone(), - server_info: server_infos.get(name).cloned(), - tools: tools_by_server.get(name).cloned().unwrap_or_default(), - resources: resources.get(name).cloned().unwrap_or_default(), - resource_templates: resource_templates.get(name).cloned().unwrap_or_default(), - auth_status: auth_statuses - .get(name) - .cloned() - .unwrap_or(CoreMcpAuthStatus::Unsupported) - .into(), + .map(|name| { + if inject_spine_ui && name == crate::spine_ui::SERVER_NAME { + crate::spine_ui::server_status(matches!(detail, McpSnapshotDetail::Full)) + } else { + McpServerStatus { + name: name.clone(), + server_info: server_infos.get(name).cloned(), + tools: tools_by_server.get(name).cloned().unwrap_or_default(), + resources: resources.get(name).cloned().unwrap_or_default(), + resource_templates: resource_templates + .get(name) + .cloned() + .unwrap_or_default(), + auth_status: auth_statuses + .get(name) + .cloned() + .unwrap_or(CoreMcpAuthStatus::Unsupported) + .into(), + } + } }) .collect(); @@ -388,6 +406,15 @@ impl McpRequestProcessor { request_id: &ConnectionRequestId, params: McpResourceReadParams, ) -> Result<(), JSONRPCErrorError> { + if crate::spine_ui::is_enabled() + && let Some(response) = crate::spine_ui::read_resource(¶ms.server, ¶ms.uri) + { + self.outgoing + .send_response(request_id.clone(), response) + .await; + return Ok(()); + } + let outgoing = Arc::clone(&self.outgoing); let McpResourceReadParams { thread_id, @@ -457,9 +484,22 @@ impl McpRequestProcessor { request_id: &ConnectionRequestId, params: McpServerToolCallParams, ) -> Result<(), JSONRPCErrorError> { + let (thread_id, thread) = self.load_thread(¶ms.thread_id).await?; + if crate::spine_ui::is_enabled() + && crate::spine_ui::is_internal_tool(¶ms.server, ¶ms.tool) + { + let state = self + .thread_state_manager + .spine_ui_state_for_thread(thread_id) + .await; + let response = crate::spine_ui::tool_call_response(¶ms.thread_id, state.as_ref()); + self.outgoing + .send_response(request_id.clone(), response) + .await; + return Ok(()); + } let outgoing = Arc::clone(&self.outgoing); let thread_id = params.thread_id.clone(); - let (_, thread) = self.load_thread(&thread_id).await?; let meta = with_mcp_tool_call_thread_id_meta(params.meta, &thread_id); let request_id = request_id.clone(); diff --git a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs index 4078cd44e..d8bbae72e 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -1,7 +1,9 @@ use super::*; +use crate::spine_ui::SpineUiState; use codex_protocol::config_types::MultiAgentMode; pub(super) const THREAD_UNLOADING_DELAY: Duration = Duration::from_secs(30 * 60); +const SPINE_UI_TERMINAL_BARRIER_TIMEOUT: Duration = Duration::from_secs(10); #[derive(Clone)] pub(super) struct ListenerTaskContext { @@ -262,6 +264,10 @@ pub(super) async fn ensure_listener_task_running( .register_listener_command_tx(conversation_id, listener_command_tx); (listener_command_rx, listener_generation) }; + listener_task_context + .thread_state_manager + .note_spine_ui_listener_generation(conversation_id, listener_generation) + .await; let ListenerTaskContext { outgoing, thread_manager, @@ -308,6 +314,62 @@ pub(super) async fn ensure_listener_task_running( } }; + if matches!(&event.msg, EventMsg::TurnStarted(_)) { + thread_state_manager + .note_spine_ui_agent_turn_started( + conversation_id, + listener_generation, + ) + .await; + } + + if crate::spine_ui::is_enabled() + && matches!(&event.msg, EventMsg::TurnComplete(_) | EventMsg::TurnAborted(_)) + { + let (agent_states, timed_out) = thread_state_manager + .wait_for_spine_ui_terminal_children( + conversation_id, + &event.id, + SPINE_UI_TERMINAL_BARRIER_TIMEOUT, + ) + .await; + if !timed_out.is_empty() { + let child_thread_ids = timed_out + .iter() + .map(|(thread_id, _)| *thread_id) + .collect::>(); + tracing::warn!( + thread_id = %conversation_id, + turn_id = %event.id, + child_thread_ids = ?child_thread_ids, + "Spine UI terminal barrier timed out; using latest child states" + ); + } + let mut state = thread_state.lock().await; + if state.live_spine_ui(&event.id).is_some() { + for (child_thread_id, generation, child_state) in agent_states { + if let Some(child_state) = child_state { + state.record_spine_ui_agent_state( + child_thread_id, + generation, + child_state, + ); + } else { + state.invalidate_spine_ui_agent_state( + child_thread_id, + generation, + ); + } + } + for (child_thread_id, generation) in timed_out { + state.mark_spine_ui_agent_sync_timeout( + child_thread_id, + generation, + ); + } + } + } + // Track the event before emitting any typed translations // so thread-local state such as raw event opt-in stays // synchronized with the conversation. @@ -316,6 +378,25 @@ pub(super) async fn ensure_listener_task_running( thread_state.track_current_turn_event(&event.id, &event.msg); thread_state.experimental_raw_events }; + if crate::spine_ui::is_enabled() + && let EventMsg::SpineSpawnProgress(progress) = &event.msg + { + thread_state_manager + .register_spine_ui_spawn_progress( + conversation_id, + &event.id, + progress, + ) + .await; + } + if crate::spine_ui::is_enabled() + && matches!(&event.msg, EventMsg::ThreadRolledBack(_)) + { + thread_state.lock().await.reset_spine_ui_after_rollback(); + thread_state_manager + .clear_all_spine_ui_routes_for_thread(conversation_id) + .await; + } if matches!(&event.msg, EventMsg::RawResponseItem(_)) && !raw_events_enabled { continue; } @@ -340,6 +421,30 @@ pub(super) async fn ensure_listener_task_running( fallback_model_provider.clone(), ) .await; + if matches!( + &event.msg, + EventMsg::TurnStarted(_) + | EventMsg::SpineTreeUpdate(_) + | EventMsg::SpineSpawnProgress(_) + | EventMsg::TurnComplete(_) + | EventMsg::TurnAborted(_) + ) { + thread_state_manager + .queue_spine_ui_agent_state(conversation_id) + .await; + } + if matches!(&event.msg, EventMsg::TurnComplete(_) | EventMsg::TurnAborted(_)) { + thread_state_manager + .acknowledge_spine_ui_agent_terminal( + conversation_id, + listener_generation, + &event.id, + ) + .await; + thread_state_manager + .clear_spine_ui_parent_routes(conversation_id, &event.id) + .await; + } } unloading_watchers_open = unloading_state.wait_for_unloading_trigger() => { if !unloading_watchers_open { @@ -377,10 +482,20 @@ pub(super) async fn ensure_listener_task_running( } } - let mut thread_state = thread_state.lock().await; - if thread_state.listener_generation == listener_generation { - thread_state_manager.unregister_listener_command_tx(conversation_id); - thread_state.clear_listener(); + let listener_was_current = { + let mut thread_state = thread_state.lock().await; + if thread_state.listener_generation == listener_generation { + thread_state_manager.unregister_listener_command_tx(conversation_id); + thread_state.clear_listener(); + true + } else { + false + } + }; + if listener_was_current { + thread_state_manager + .clear_spine_ui_routes_for_listener_exit(conversation_id, listener_generation) + .await; } }); Ok(()) @@ -509,6 +624,139 @@ pub(super) async fn handle_thread_listener_command( .await; let _ = completion_tx.send(()); } + ThreadListenerCommand::ForwardSpineUiAgentState { + child_thread_id, + parent_turn_id, + generation, + state: child_state, + terminal, + } => { + if !thread_state_manager + .spine_ui_route_is_current( + child_thread_id, + conversation_id, + &parent_turn_id, + generation, + ) + .await + { + return; + } + let forwarded_revision = child_state.as_ref().map(SpineUiState::revision); + let (spine_ui, late_terminal_refresh) = { + let mut state = thread_state.lock().await; + if state.live_spine_ui(&parent_turn_id).is_some() { + let changed = child_state.is_some_and(|child_state| { + state.record_spine_ui_agent_state(child_thread_id, generation, child_state) + }); + ( + changed + .then(|| state.live_spine_ui(&parent_turn_id).cloned()) + .flatten(), + None, + ) + } else if terminal { + ( + None, + state.record_completed_spine_ui_agent_terminal( + &parent_turn_id, + child_thread_id, + generation, + child_state, + ), + ) + } else { + (None, None) + } + }; + if let Some(spine_ui) = spine_ui { + let connection_ids = thread_state_manager + .subscribed_connection_ids(conversation_id) + .await; + let outgoing = ThreadScopedOutgoingMessageSender::new( + outgoing.clone(), + connection_ids, + conversation_id, + ); + if let Some(notification) = crate::spine_ui::snapshot_started_notification( + &conversation_id.to_string(), + &parent_turn_id, + &spine_ui, + ) { + outgoing + .send_server_notification(ServerNotification::ItemStarted(notification)) + .await; + } + thread_state_manager + .queue_spine_ui_agent_state(conversation_id) + .await; + } + if let Some(refresh) = late_terminal_refresh { + if let Some(notification) = crate::spine_ui::snapshot_completed_notification( + &conversation_id.to_string(), + &parent_turn_id, + &refresh.state, + ) { + let mut connection_ids = thread_state_manager + .subscribed_connection_ids(conversation_id) + .await; + connection_ids + .retain(|connection_id| refresh.connection_ids.contains(connection_id)); + ThreadScopedOutgoingMessageSender::new( + outgoing.clone(), + connection_ids, + conversation_id, + ) + .send_server_notification(ServerNotification::ItemCompleted(notification)) + .await; + } + thread_state_manager + .queue_spine_ui_agent_terminal_refresh(conversation_id) + .await; + } + if !terminal && let Some(forwarded_revision) = forwarded_revision { + thread_state_manager + .complete_spine_ui_agent_state_forward( + child_thread_id, + conversation_id, + &parent_turn_id, + generation, + forwarded_revision, + ) + .await; + } + if terminal { + thread_state_manager + .complete_spine_ui_late_terminal( + child_thread_id, + conversation_id, + &parent_turn_id, + generation, + ) + .await; + thread_state_manager + .clear_spine_ui_parent_routes(conversation_id, &parent_turn_id) + .await; + } + } + ThreadListenerCommand::EmitSpineUiInvalidation { turn_id, state } => { + if let Some(notification) = crate::spine_ui::snapshot_started_notification( + &conversation_id.to_string(), + &turn_id, + &state, + ) { + let connection_ids = thread_state_manager + .subscribed_connection_ids(conversation_id) + .await; + ThreadScopedOutgoingMessageSender::new( + outgoing.clone(), + connection_ids, + conversation_id, + ) + .send_server_notification(ServerNotification::ItemStarted(notification)) + .await; + } + } } } diff --git a/codex-rs/app-server/src/spine_ui.rs b/codex-rs/app-server/src/spine_ui.rs new file mode 100644 index 000000000..37347a8b2 --- /dev/null +++ b/codex-rs/app-server/src/spine_ui.rs @@ -0,0 +1,423 @@ +use codex_protocol::ThreadId; +use codex_protocol::protocol::SpineSpawnOutcome; +use codex_protocol::protocol::SpineSpawnProgressEvent; +use codex_protocol::protocol::SpineSpawnTaskProgress; +use codex_protocol::protocol::SpineTreeUpdateEvent; +use std::collections::HashMap; +use std::collections::HashSet; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +mod mcp; +mod render; + +pub(crate) use mcp::is_enabled; +pub(crate) use mcp::is_internal_tool; +pub(crate) use mcp::is_tree_tool_call; +pub(crate) use mcp::read_resource; +pub(crate) use mcp::server_status; +pub(crate) use mcp::snapshot_completed_notification; +pub(crate) use mcp::snapshot_started_notification; +pub(crate) use mcp::tool_call_response; + +pub(crate) const ENABLE_ENV: &str = "CODEX_SPINE_APP_UI"; +pub(crate) const SERVER_NAME: &str = "__codex_internal_spine_tree_ui__"; +pub(crate) const TOOL_NAME: &str = "spine_tree"; +pub(crate) const RESOURCE_URI: &str = "ui://spine/tree.html"; +const ITEM_ID_PREFIX: &str = "spine-ui-"; +const RESOURCE_MIME_TYPE: &str = "text/html;profile=mcp-app"; +const RESOURCE_HTML: &str = include_str!("spine_ui/tree.html"); + +#[derive(Clone, Debug, PartialEq, Eq)] +struct SpineUiSpawnTask { + progress: SpineSpawnTaskProgress, + result_node_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct SpineUiSpawnCall { + call_id: String, + parent_node_id: Option, + tasks: Vec, +} + +#[derive(Clone, Debug)] +struct SpineUiAgentState { + generation: u64, + state: SpineUiState, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct SpineUiState { + revision: u64, + started_at_ms: Option, + completed_at_ms: Option, + snapshot: Option, + spawn_calls: Vec, + settled_spawn_call_ids: HashSet, + agent_subtrees: HashMap, + invalidated_agent_generations: HashMap, + agent_sync_timeout_generations: HashMap, +} + +impl SpineUiState { + pub(crate) fn record_snapshot(&mut self, snapshot: SpineTreeUpdateEvent) -> bool { + if let Some(current) = self.snapshot.as_ref() + && (snapshot.snapshot_seq < current.snapshot_seq + || (snapshot.snapshot_seq == current.snapshot_seq && snapshot == *current)) + { + return false; + } + for call in &mut self.spawn_calls { + if call.parent_node_id.is_none() { + call.parent_node_id = Some(snapshot.active_node_id.clone()); + } + } + self.settled_spawn_call_ids + .extend(snapshot.settled_spawn_call_ids.iter().cloned()); + self.reconcile_spawn_result_nodes(&snapshot); + let visible_agent_thread_ids = self + .spawn_calls + .iter() + .flat_map(|call| call.tasks.iter().map(|task| task.progress.thread_id)) + .collect::>(); + self.agent_subtrees + .retain(|thread_id, _| visible_agent_thread_ids.contains(thread_id)); + self.invalidated_agent_generations + .retain(|thread_id, _| visible_agent_thread_ids.contains(thread_id)); + self.agent_sync_timeout_generations + .retain(|thread_id, _| visible_agent_thread_ids.contains(thread_id)); + self.settled_spawn_call_ids + .retain(|call_id| self.spawn_calls.iter().any(|call| &call.call_id == call_id)); + self.snapshot = Some(snapshot); + self.started_at_ms.get_or_insert_with(now_unix_timestamp_ms); + self.bump_revision(); + true + } + + pub(crate) fn record_spawn_progress(&mut self, progress: SpineSpawnProgressEvent) -> bool { + if self.settled_spawn_call_ids.contains(&progress.call_id) { + return false; + } + let parent_node_id = self + .snapshot + .as_ref() + .map(|snapshot| snapshot.active_node_id.clone()); + if let Some(existing) = self + .spawn_calls + .iter_mut() + .find(|call| call.call_id == progress.call_id) + { + let result_node_ids = existing + .tasks + .iter() + .map(|task| (task.progress.ordinal, task.result_node_id.clone())) + .collect::>(); + let previous = existing.tasks.clone(); + existing.tasks = progress + .tasks + .into_iter() + .map(|mut progress| { + if let Some(old) = previous + .iter() + .find(|task| task.progress.ordinal == progress.ordinal) + && (status_rank(&old.progress.status) >= 2 + || status_rank(&old.progress.status) > status_rank(&progress.status)) + { + progress.status = old.progress.status.clone(); + } + SpineUiSpawnTask { + result_node_id: result_node_ids.get(&progress.ordinal).cloned().flatten(), + progress, + } + }) + .collect(); + if existing.parent_node_id.is_none() { + existing.parent_node_id = parent_node_id; + } + } else { + self.spawn_calls.push(SpineUiSpawnCall { + call_id: progress.call_id, + parent_node_id, + tasks: progress + .tasks + .into_iter() + .map(|progress| SpineUiSpawnTask { + progress, + result_node_id: None, + }) + .collect(), + }); + } + self.started_at_ms.get_or_insert_with(now_unix_timestamp_ms); + self.bump_revision(); + true + } + + pub(crate) fn record_agent_state( + &mut self, + thread_id: ThreadId, + generation: u64, + state: SpineUiState, + ) -> bool { + let is_known_agent = self.spawn_calls.iter().any(|call| { + call.tasks + .iter() + .any(|task| task.progress.thread_id == thread_id) + }); + if !is_known_agent + || self + .invalidated_agent_generations + .get(&thread_id) + .is_some_and(|invalidated| generation <= *invalidated) + || self.agent_subtrees.get(&thread_id).is_some_and(|current| { + generation < current.generation + || (generation == current.generation + && state.revision <= current.state.revision) + }) + { + return false; + } + for call in &mut self.spawn_calls { + if let Some(task) = call + .tasks + .iter_mut() + .find(|task| task.progress.thread_id == thread_id) + && matches!( + task.progress.status, + codex_protocol::protocol::AgentStatus::PendingInit + ) + { + task.progress.status = codex_protocol::protocol::AgentStatus::Running; + } + } + self.agent_subtrees + .insert(thread_id, SpineUiAgentState { generation, state }); + if self + .agent_sync_timeout_generations + .get(&thread_id) + .is_some_and(|timed_out| generation > *timed_out) + { + self.agent_sync_timeout_generations.remove(&thread_id); + } + self.bump_revision(); + true + } + + pub(crate) fn mark_agent_sync_timeout(&mut self, thread_id: ThreadId, generation: u64) -> bool { + let is_known_agent = self.spawn_calls.iter().any(|call| { + call.tasks + .iter() + .any(|task| task.progress.thread_id == thread_id) + }); + if !is_known_agent + || self + .invalidated_agent_generations + .get(&thread_id) + .is_some_and(|invalidated| generation <= *invalidated) + || self + .agent_subtrees + .get(&thread_id) + .is_some_and(|current| generation < current.generation) + || self.agent_sync_timeout_generations.get(&thread_id) == Some(&generation) + { + return false; + } + self.agent_sync_timeout_generations + .insert(thread_id, generation); + self.bump_revision(); + true + } + + pub(crate) fn clear_agent_sync_timeout( + &mut self, + thread_id: ThreadId, + generation: u64, + ) -> bool { + if self.agent_sync_timeout_generations.get(&thread_id) != Some(&generation) { + return false; + } + self.agent_sync_timeout_generations.remove(&thread_id); + self.bump_revision(); + true + } + + pub(crate) fn remove_agent_state(&mut self, thread_id: ThreadId, generation: u64) -> bool { + self.invalidated_agent_generations + .entry(thread_id) + .and_modify(|invalidated| *invalidated = (*invalidated).max(generation)) + .or_insert(generation); + let should_remove_subtree = self + .agent_subtrees + .get(&thread_id) + .is_some_and(|current| current.generation <= generation); + let should_remove_timeout = self + .agent_sync_timeout_generations + .get(&thread_id) + .is_some_and(|current| *current <= generation); + if !should_remove_subtree && !should_remove_timeout { + return false; + } + if should_remove_subtree { + self.agent_subtrees.remove(&thread_id); + } + if should_remove_timeout { + self.agent_sync_timeout_generations.remove(&thread_id); + } + self.bump_revision(); + true + } + + pub(crate) fn latest_snapshot(&self) -> Option<&SpineTreeUpdateEvent> { + self.snapshot.as_ref() + } + + pub(crate) fn filtered_for_parent(&self, baseline_node_ids: &HashSet) -> Self { + let mut filtered = self.clone(); + if let Some(snapshot) = filtered.snapshot.as_mut() { + snapshot + .nodes + .retain(|node| !baseline_node_ids.contains(&node.node_id)); + if !snapshot + .nodes + .iter() + .any(|node| node.node_id == snapshot.active_node_id) + && let Some(node) = snapshot.nodes.last() + { + snapshot.active_node_id = node.node_id.clone(); + } + } + for call in &mut filtered.spawn_calls { + if call + .parent_node_id + .as_ref() + .is_some_and(|node_id| baseline_node_ids.contains(node_id)) + { + call.parent_node_id = None; + } + } + filtered + } + + pub(crate) fn carry_forward(&self) -> Self { + Self { + revision: self.revision, + started_at_ms: None, + completed_at_ms: None, + snapshot: None, + spawn_calls: self.spawn_calls.clone(), + settled_spawn_call_ids: self.settled_spawn_call_ids.clone(), + agent_subtrees: self.agent_subtrees.clone(), + invalidated_agent_generations: self.invalidated_agent_generations.clone(), + agent_sync_timeout_generations: self.agent_sync_timeout_generations.clone(), + } + } + + pub(crate) fn revision(&self) -> u64 { + self.revision + } + + pub(crate) fn has_agent_sync_timeout(&self) -> bool { + !self.agent_sync_timeout_generations.is_empty() + || self + .agent_subtrees + .values() + .any(|agent| agent.state.has_agent_sync_timeout()) + } + + pub(crate) fn set_revision(&mut self, revision: u64) { + self.revision = revision; + } + + pub(crate) fn mark_completed(&mut self) { + self.completed_at_ms + .get_or_insert_with(now_unix_timestamp_ms); + } + + pub(crate) fn started_at_ms(&self) -> Option { + self.started_at_ms + } + + pub(crate) fn completed_at_ms(&self) -> Option { + self.completed_at_ms + } + + pub(crate) fn structured_content(&self) -> Option { + render::structured_content(self) + } + + fn reconcile_spawn_result_nodes(&mut self, snapshot: &SpineTreeUpdateEvent) { + let mut claimed_node_ids = self + .spawn_calls + .iter() + .flat_map(|call| call.tasks.iter()) + .filter_map(|task| task.result_node_id.clone()) + .collect::>(); + + for call in &mut self.spawn_calls { + if !self.settled_spawn_call_ids.contains(&call.call_id) { + continue; + } + for task in &mut call.tasks { + if task.result_node_id.is_some() { + continue; + } + let Some(node) = snapshot.nodes.iter().find(|node| { + node.spawn_outcome.is_some() + && node.parent_id == call.parent_node_id + && node.summary.as_deref() == Some(task.progress.summary.as_str()) + && !claimed_node_ids.contains(&node.node_id) + }) else { + continue; + }; + task.progress.status = match node.spawn_outcome { + Some(SpineSpawnOutcome::Completed) => { + codex_protocol::protocol::AgentStatus::Completed(None) + } + Some(SpineSpawnOutcome::Errored) => { + codex_protocol::protocol::AgentStatus::Errored( + node.memory_summary + .clone() + .unwrap_or_else(|| "Agent failed".to_string()), + ) + } + Some(SpineSpawnOutcome::Aborted) => { + codex_protocol::protocol::AgentStatus::Shutdown + } + None => continue, + }; + task.result_node_id = Some(node.node_id.clone()); + claimed_node_ids.insert(node.node_id.clone()); + } + } + } + + fn bump_revision(&mut self) { + self.revision = self.revision.saturating_add(1); + } +} + +fn status_rank(status: &codex_protocol::protocol::AgentStatus) -> u8 { + match status { + codex_protocol::protocol::AgentStatus::PendingInit => 0, + codex_protocol::protocol::AgentStatus::Running => 1, + codex_protocol::protocol::AgentStatus::Interrupted => 2, + codex_protocol::protocol::AgentStatus::Completed(_) + | codex_protocol::protocol::AgentStatus::Errored(_) + | codex_protocol::protocol::AgentStatus::Shutdown + | codex_protocol::protocol::AgentStatus::NotFound => 3, + } +} + +fn now_unix_timestamp_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(i64::MAX) +} + +#[cfg(test)] +#[path = "spine_ui_tests.rs"] +mod tests; diff --git a/codex-rs/app-server/src/spine_ui/mcp.rs b/codex-rs/app-server/src/spine_ui/mcp.rs new file mode 100644 index 000000000..b04d52fe5 --- /dev/null +++ b/codex-rs/app-server/src/spine_ui/mcp.rs @@ -0,0 +1,257 @@ +use super::ENABLE_ENV; +use super::ITEM_ID_PREFIX; +use super::RESOURCE_HTML; +use super::RESOURCE_MIME_TYPE; +use super::RESOURCE_URI; +use super::SERVER_NAME; +use super::SpineUiState; +use super::TOOL_NAME; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::McpAuthStatus; +use codex_app_server_protocol::McpResourceContent; +use codex_app_server_protocol::McpResourceReadResponse; +use codex_app_server_protocol::McpServerStatus; +use codex_app_server_protocol::McpServerToolCallResponse; +use codex_app_server_protocol::McpToolCallStatus; +use codex_app_server_protocol::ThreadItem; +use codex_protocol::items::McpToolCallItem; +use codex_protocol::items::McpToolCallStatus as CoreMcpToolCallStatus; +use codex_protocol::items::TurnItem as CoreTurnItem; +use codex_protocol::mcp::CallToolResult; +use codex_protocol::mcp::McpServerInfo; +use codex_protocol::mcp::Resource; +use codex_protocol::mcp::Tool; +use codex_protocol::models::ResponseItem; +use serde_json::json; +use std::collections::HashMap; + +pub(crate) fn is_enabled() -> bool { + enabled_from_env_value(std::env::var(ENABLE_ENV).ok().as_deref()) +} + +fn enabled_from_env_value(value: Option<&str>) -> bool { + value.is_some_and(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "on" + ) + }) +} + +pub(crate) fn is_tree_tool_call(item: &ResponseItem) -> bool { + let ResponseItem::FunctionCall { + name, namespace, .. + } = item + else { + return false; + }; + let tool = match namespace.as_deref() { + Some("spine") => name.as_str(), + None => name.strip_prefix("spine.").unwrap_or_default(), + Some(_) => return false, + }; + matches!(tool, "open" | "next" | "close" | "spawn") +} + +pub(crate) fn read_resource(server: &str, uri: &str) -> Option { + (server == SERVER_NAME && uri == RESOURCE_URI).then(|| McpResourceReadResponse { + contents: vec![McpResourceContent::Text { + uri: uri.to_string(), + mime_type: Some(RESOURCE_MIME_TYPE.to_string()), + text: RESOURCE_HTML.to_string(), + meta: Some(json!({ + "ui": { + "prefersBorder": true, + "csp": { + "connectDomains": [], + "resourceDomains": [] + } + }, + "openai/widgetHeightHint": 1, + "openai/widgetMinFrameHeight": 1 + })), + }], + }) +} + +pub(crate) fn is_internal_tool(server: &str, tool: &str) -> bool { + server == SERVER_NAME && tool == TOOL_NAME +} + +pub(crate) fn tool_call_response( + thread_id: &str, + state: Option<&SpineUiState>, +) -> McpServerToolCallResponse { + let Some(structured_content) = state.and_then(SpineUiState::structured_content) else { + return McpServerToolCallResponse { + content: vec![json!({ + "type": "text", + "text": "No Spine Tree is active for this thread." + })], + structured_content: None, + is_error: Some(true), + meta: None, + }; + }; + McpServerToolCallResponse { + content: vec![json!({ + "type": "text", + "text": "Spine Tree" + })], + structured_content: Some(structured_content), + is_error: Some(false), + meta: Some(json!({ + "openai/widgetSessionId": format!("spine-ui-tool-{thread_id}") + })), + } +} + +pub(crate) fn server_status(include_resources: bool) -> McpServerStatus { + let tool = Tool { + name: TOOL_NAME.to_string(), + title: Some("Spine Tree".to_string()), + description: Some( + "Host-managed read-only view of the current Spine task tree.".to_string(), + ), + input_schema: json!({"type": "object", "properties": {}, "additionalProperties": false}), + output_schema: None, + annotations: Some(json!({ + "readOnlyHint": true, + "destructiveHint": false, + "openWorldHint": false + })), + icons: None, + meta: Some(json!({"ui": {"resourceUri": RESOURCE_URI}})), + }; + McpServerStatus { + name: SERVER_NAME.to_string(), + server_info: Some(McpServerInfo { + name: SERVER_NAME.to_string(), + title: Some("Spine UI".to_string()), + version: "1".to_string(), + description: Some("Read-only Spine task tree UI.".to_string()), + icons: None, + website_url: None, + }), + tools: HashMap::from([(tool.name.clone(), tool)]), + resources: include_resources + .then(|| registered_resource(RESOURCE_URI, "spine-tree", "Spine Tree")) + .into_iter() + .collect(), + resource_templates: Vec::new(), + auth_status: McpAuthStatus::Unsupported, + } +} + +fn registered_resource(uri: &str, name: &str, title: &str) -> Resource { + Resource { + annotations: None, + description: Some("Spine task tree component".to_string()), + mime_type: Some(RESOURCE_MIME_TYPE.to_string()), + name: name.to_string(), + size: None, + title: Some(title.to_string()), + uri: uri.to_string(), + icons: None, + meta: None, + } +} + +pub(crate) fn snapshot_started_notification( + thread_id: &str, + turn_id: &str, + state: &SpineUiState, +) -> Option { + Some(ItemStartedNotification { + item: snapshot_item(turn_id, state, McpToolCallStatus::InProgress)?, + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + started_at_ms: state.started_at_ms()?, + }) +} + +pub(crate) fn snapshot_completed_notification( + thread_id: &str, + turn_id: &str, + state: &SpineUiState, +) -> Option { + Some(ItemCompletedNotification { + item: snapshot_item(turn_id, state, McpToolCallStatus::Completed)?, + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + completed_at_ms: state.completed_at_ms()?, + }) +} + +fn snapshot_item( + turn_id: &str, + state: &SpineUiState, + status: McpToolCallStatus, +) -> Option { + Some(ThreadItem::from(snapshot_core_item( + turn_id, + state, + match status { + McpToolCallStatus::InProgress => CoreMcpToolCallStatus::InProgress, + McpToolCallStatus::Completed => CoreMcpToolCallStatus::Completed, + McpToolCallStatus::Failed => CoreMcpToolCallStatus::Failed, + }, + )?)) +} + +fn snapshot_core_item( + turn_id: &str, + state: &SpineUiState, + status: CoreMcpToolCallStatus, +) -> Option { + let structured_content = state.structured_content()?; + let snapshot_seq = state.snapshot.as_ref()?.snapshot_seq; + // Codex supersedes older widgets that share a server/resource unless the host + // gives each independently mounted turn a stable widget session. + let item_id = format!("{ITEM_ID_PREFIX}{turn_id}"); + Some(CoreTurnItem::McpToolCall(McpToolCallItem { + id: item_id.clone(), + server: SERVER_NAME.to_string(), + tool: TOOL_NAME.to_string(), + status, + arguments: json!({}), + connector_id: Some(SERVER_NAME.to_string()), + mcp_app_resource_uri: Some(RESOURCE_URI.to_string()), + link_id: None, + app_name: Some("Spine UI".to_string()), + template_id: None, + action_name: Some(TOOL_NAME.to_string()), + plugin_id: None, + result: Some(CallToolResult { + content: vec![json!({ + "type": "text", + "text": format!("Spine snapshot {snapshot_seq}"), + })], + structured_content: Some(structured_content), + is_error: Some(false), + meta: Some(json!({ + "ui/resourceUri": RESOURCE_URI, + "openai/widgetSessionId": item_id, + })), + }), + error: None, + duration: None, + })) +} + +#[cfg(test)] +mod tests { + use super::enabled_from_env_value; + + #[test] + fn enable_env_parser_is_strict() { + for enabled in ["1", "true", "TRUE", " on "] { + assert!(enabled_from_env_value(Some(enabled)), "{enabled}"); + } + for disabled in ["", "0", "false", "off", "yes", "enabled"] { + assert!(!enabled_from_env_value(Some(disabled)), "{disabled}"); + } + assert!(!enabled_from_env_value(None)); + } +} diff --git a/codex-rs/app-server/src/spine_ui/render.rs b/codex-rs/app-server/src/spine_ui/render.rs new file mode 100644 index 000000000..00bc2f7f8 --- /dev/null +++ b/codex-rs/app-server/src/spine_ui/render.rs @@ -0,0 +1,160 @@ +use super::SpineUiState; +use codex_protocol::ThreadId; +use codex_protocol::protocol::AgentStatus; +use codex_protocol::protocol::SpineSpawnOutcome; +use codex_protocol::protocol::SpineTreeNodeKind; +use codex_protocol::protocol::SpineTreeNodeStatus; +use serde::Serialize; +use serde_json::json; +use std::collections::HashSet; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SpineUiAgentSubtree { + thread_id: ThreadId, + #[serde(flatten)] + content: serde_json::Value, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SpineUiRenderSnapshot<'a> { + active_node_id: &'a str, + nodes: Vec>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SpineUiRenderNode<'a> { + node_id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + parent_id: Option<&'a str>, + kind: SpineTreeNodeKind, + status: SpineTreeNodeStatus, + #[serde(skip_serializing_if = "Option::is_none")] + summary: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + spawn_outcome: Option, + start: u64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SpineUiRenderSpawnCall<'a> { + call_id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + parent_node_id: Option<&'a str>, + tasks: Vec>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SpineUiRenderSpawnTask<'a> { + ordinal: u32, + summary: &'a str, + thread_id: ThreadId, + status: SpineUiRenderAgentStatus, + #[serde(skip_serializing_if = "Option::is_none")] + result_node_id: Option<&'a str>, +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "snake_case")] +enum SpineUiRenderAgentStatus { + Pending, + Running, + Interrupted, + Completed, + Error, + Shutdown, + NotFound, +} + +impl From<&AgentStatus> for SpineUiRenderAgentStatus { + fn from(status: &AgentStatus) -> Self { + match status { + AgentStatus::PendingInit => Self::Pending, + AgentStatus::Running => Self::Running, + AgentStatus::Interrupted => Self::Interrupted, + AgentStatus::Completed(_) => Self::Completed, + AgentStatus::Errored(_) => Self::Error, + AgentStatus::Shutdown => Self::Shutdown, + AgentStatus::NotFound => Self::NotFound, + } + } +} + +pub(super) fn structured_content(state: &SpineUiState) -> Option { + let snapshot = state.snapshot.as_ref()?; + let visible_node_ids = snapshot + .nodes + .iter() + .map(|node| node.node_id.as_str()) + .collect::>(); + let mut seen_agent_threads = HashSet::new(); + let agent_subtrees = state + .spawn_calls + .iter() + .flat_map(|call| call.tasks.iter()) + .filter_map(|task| { + let thread_id = task.progress.thread_id; + let child_state = &state.agent_subtrees.get(&thread_id)?.state; + let content = structured_content(child_state)?; + seen_agent_threads + .insert(thread_id) + .then_some(SpineUiAgentSubtree { thread_id, content }) + }) + .collect::>(); + let snapshot = SpineUiRenderSnapshot { + active_node_id: &snapshot.active_node_id, + nodes: snapshot + .nodes + .iter() + .map(|node| SpineUiRenderNode { + node_id: &node.node_id, + parent_id: node.parent_id.as_deref(), + kind: node.kind, + status: node.status, + summary: node.summary.as_deref(), + spawn_outcome: node.spawn_outcome, + start: node.start, + }) + .collect(), + }; + let spawn_calls = state + .spawn_calls + .iter() + .map(|call| SpineUiRenderSpawnCall { + call_id: &call.call_id, + parent_node_id: call + .parent_node_id + .as_deref() + .filter(|parent_node_id| visible_node_ids.contains(parent_node_id)), + tasks: call + .tasks + .iter() + .map(|task| SpineUiRenderSpawnTask { + ordinal: task.progress.ordinal, + summary: &task.progress.summary, + thread_id: task.progress.thread_id, + status: if state + .agent_sync_timeout_generations + .contains_key(&task.progress.thread_id) + { + SpineUiRenderAgentStatus::Error + } else { + (&task.progress.status).into() + }, + result_node_id: task.result_node_id.as_deref(), + }) + .collect(), + }) + .collect::>(); + Some(json!({ + "schemaVersion": 1, + "uiRevision": state.revision, + "snapshot": snapshot, + "spawnCalls": spawn_calls, + "agentSubtrees": agent_subtrees, + })) +} diff --git a/codex-rs/app-server/src/spine_ui/tree.html b/codex-rs/app-server/src/spine_ui/tree.html new file mode 100644 index 000000000..872650aac --- /dev/null +++ b/codex-rs/app-server/src/spine_ui/tree.html @@ -0,0 +1,646 @@ + + + + + + + + + + +
+
+
    +
    +
    + + + diff --git a/codex-rs/app-server/src/spine_ui_tests.rs b/codex-rs/app-server/src/spine_ui_tests.rs new file mode 100644 index 000000000..2badc9cac --- /dev/null +++ b/codex-rs/app-server/src/spine_ui_tests.rs @@ -0,0 +1,447 @@ +use super::*; +use codex_app_server_protocol::McpToolCallStatus; +use codex_app_server_protocol::ThreadItem; +use codex_protocol::AgentPath; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::AgentStatus; +use codex_protocol::protocol::SpineSpawnOutcome; +use codex_protocol::protocol::SpineTreeNodeKind; +use codex_protocol::protocol::SpineTreeNodeSnapshot; +use codex_protocol::protocol::SpineTreeNodeStatus; +use pretty_assertions::assert_eq; +use serde_json::json; + +fn snapshot(sequence: u64, active_node_id: &str) -> SpineTreeUpdateEvent { + SpineTreeUpdateEvent { + snapshot_seq: sequence, + active_node_id: active_node_id.to_string(), + settled_spawn_call_ids: Vec::new(), + nodes: vec![ + node( + "1", + None, + SpineTreeNodeKind::RootEpoch, + SpineTreeNodeStatus::Opened, + None, + ), + node( + "1.1", + Some("1"), + SpineTreeNodeKind::Task, + if active_node_id == "1.1" { + SpineTreeNodeStatus::Live + } else { + SpineTreeNodeStatus::Closed + }, + Some("Render the Spine tree"), + ), + node( + "1.2", + Some("1"), + SpineTreeNodeKind::Task, + if active_node_id == "1.2" { + SpineTreeNodeStatus::Live + } else { + SpineTreeNodeStatus::Opened + }, + Some("Verify the result"), + ), + ], + } +} + +fn node( + node_id: &str, + parent_id: Option<&str>, + kind: SpineTreeNodeKind, + status: SpineTreeNodeStatus, + summary: Option<&str>, +) -> SpineTreeNodeSnapshot { + SpineTreeNodeSnapshot { + node_id: node_id.to_string(), + parent_id: parent_id.map(str::to_string), + kind, + status, + summary: summary.map(str::to_string), + memory_summary: Some("not sent to the renderer".to_string()), + spawn_outcome: None, + start: 0, + end: Some(10), + context_pressure: None, + } +} + +fn spawn_progress(call_id: &str, status: AgentStatus) -> SpineSpawnProgressEvent { + spawn_progress_for_thread(call_id, ThreadId::new(), status) +} + +fn spawn_progress_for_thread( + call_id: &str, + thread_id: ThreadId, + status: AgentStatus, +) -> SpineSpawnProgressEvent { + SpineSpawnProgressEvent { + call_id: call_id.to_string(), + tasks: vec![SpineSpawnTaskProgress { + ordinal: 0, + summary: format!("Run {call_id}"), + thread_id, + agent_path: Some( + AgentPath::try_from(format!("/root/{call_id}")).expect("valid agent path"), + ), + status, + }], + } +} + +#[test] +fn only_tree_affecting_spine_calls_activate_the_ui() { + for tool in ["open", "next", "close", "spawn"] { + assert!(is_tree_tool_call(&function_call(tool, Some("spine")))); + assert!(is_tree_tool_call(&function_call( + &format!("spine.{tool}"), + None + ))); + } + assert!(!is_tree_tool_call(&function_call("trim", Some("spine")))); + assert!(!is_tree_tool_call(&function_call("open", Some("other")))); + assert!(!is_tree_tool_call(&function_call("shell", None))); +} + +fn function_call(name: &str, namespace: Option<&str>) -> ResponseItem { + ResponseItem::FunctionCall { + id: None, + name: name.to_string(), + namespace: namespace.map(str::to_string), + arguments: "{}".to_string(), + call_id: format!("call-{name}"), + internal_chat_message_metadata_passthrough: None, + } +} + +#[test] +fn resource_is_scoped_and_self_contained() { + let response = read_resource(SERVER_NAME, RESOURCE_URI).expect("Spine UI resource"); + assert_eq!(response.contents.len(), 1); + assert!(read_resource("other", RESOURCE_URI).is_none()); + assert!(read_resource(SERVER_NAME, "ui://spine/other.html").is_none()); + assert!(RESOURCE_HTML.contains("default-src 'none'")); + assert!(RESOURCE_HTML.contains("ResizeObserver")); + assert!(RESOURCE_HTML.contains("function validTreePayload(value)")); + assert!(RESOURCE_HTML.contains("spawnCalls.flatMap")); + assert!(!RESOURCE_HTML.contains("