diff --git a/src/acp_client.rs b/src/acp_client.rs index 215b610..485285d 100644 --- a/src/acp_client.rs +++ b/src/acp_client.rs @@ -21,6 +21,10 @@ use crate::ServerChannelMsg; use crate::acp_state::{AcpAppEvent, AcpModelsMetaInfo, AcpSessionUpdate}; use crate::command::{Command, PromptBlock, SessionListRequest}; use crate::domain::auth::{OAuthFlow, OAuthResult, OAuthResultStatus}; +use crate::domain::mesh::{ + MeshInviteCreatedInfo, MeshNodesInfo, MeshScopeInfo, MeshStatusInfo, RemoteNodeInfo, + RemoteSessionAttachInfo, RemoteSessionInfo, RemoteSessionListInfo, +}; use crate::domain::model::ModelEntry; use crate::domain::profile::{AgentInfo, ProfileInfo}; use crate::domain::session::{ @@ -28,9 +32,9 @@ use crate::domain::session::{ UndoStackSnapshot, }; use crate::protocol::{ - AuthProvidersData, MeshInviteCreatedInfo, MeshNodesInfo, MeshStatusInfo, OAuthFlowDto, - OAuthResultDto, RedoResultData, RemoteSessionAttachInfo, RemoteSessionListInfo, UndoResultData, - UndoStackFrame, + AuthProvidersData, MeshInviteCreatedDto, MeshNodesDto, MeshScopeDto, MeshStatusDto, + OAuthFlowDto, OAuthResultDto, RedoResultData, RemoteNodeDto, RemoteSessionAttachDto, + RemoteSessionDto, RemoteSessionListDto, UndoResultData, UndoStackFrame, }; #[derive(Debug, Clone, PartialEq, Eq)] @@ -677,9 +681,9 @@ async fn handle_websocket_text( if let Ok(nodes_resp) = call_querymt_ext(connection, "querymt/mesh/nodes", json!({})).await && let Ok(nodes) = - serde_json::from_value::(ext_payload(&nodes_resp).clone()) + serde_json::from_value::(ext_payload(&nodes_resp).clone()) { - send_acp(srv_tx, AcpAppEvent::MeshNodes(nodes)); + send_acp(srv_tx, AcpAppEvent::MeshNodes(mesh_nodes_from_wire(nodes))); } } _ => {} @@ -1346,15 +1350,18 @@ async fn handle_command( let status_resp = call_querymt_ext(connection, "querymt/mesh/status", json!({})).await?; if let Ok(status) = - serde_json::from_value::(ext_payload(&status_resp).clone()) + serde_json::from_value::(ext_payload(&status_resp).clone()) { - send_acp(srv_tx, AcpAppEvent::MeshStatus(status)); + send_acp( + srv_tx, + AcpAppEvent::MeshStatus(mesh_status_from_wire(status)), + ); } let nodes_resp = call_querymt_ext(connection, "querymt/mesh/nodes", json!({})).await?; if let Ok(nodes) = - serde_json::from_value::(ext_payload(&nodes_resp).clone()) + serde_json::from_value::(ext_payload(&nodes_resp).clone()) { - send_acp(srv_tx, AcpAppEvent::MeshNodes(nodes)); + send_acp(srv_tx, AcpAppEvent::MeshNodes(mesh_nodes_from_wire(nodes))); } } Command::ListRemoteSessions { @@ -1369,9 +1376,12 @@ async fn handle_command( ) .await?; if let Ok(list) = - serde_json::from_value::(ext_payload(&response).clone()) + serde_json::from_value::(ext_payload(&response).clone()) { - send_acp(srv_tx, AcpAppEvent::RemoteSessions(list)); + send_acp( + srv_tx, + AcpAppEvent::RemoteSessions(remote_session_list_from_wire(list)), + ); } } Command::CreateRemoteSession { node_id, cwd } => { @@ -1382,9 +1392,12 @@ async fn handle_command( ) .await?; if let Ok(attached) = - serde_json::from_value::(ext_payload(&response).clone()) + serde_json::from_value::(ext_payload(&response).clone()) { - send_acp(srv_tx, AcpAppEvent::RemoteSessionAttached(attached)); + send_acp( + srv_tx, + AcpAppEvent::RemoteSessionAttached(remote_session_attach_from_wire(attached)), + ); } } Command::AttachRemoteSession { @@ -1398,9 +1411,12 @@ async fn handle_command( ) .await?; if let Ok(attached) = - serde_json::from_value::(ext_payload(&response).clone()) + serde_json::from_value::(ext_payload(&response).clone()) { - send_acp(srv_tx, AcpAppEvent::RemoteSessionAttached(attached)); + send_acp( + srv_tx, + AcpAppEvent::RemoteSessionAttached(remote_session_attach_from_wire(attached)), + ); } } Command::CreateMeshInvite { @@ -1415,9 +1431,12 @@ async fn handle_command( ) .await?; if let Ok(invite) = - serde_json::from_value::(ext_payload(&response).clone()) + serde_json::from_value::(ext_payload(&response).clone()) { - send_acp(srv_tx, AcpAppEvent::MeshInviteCreated(invite)); + send_acp( + srv_tx, + AcpAppEvent::MeshInviteCreated(mesh_invite_created_from_wire(invite)), + ); } } Command::ListSessionChildren { .. } @@ -1519,6 +1538,93 @@ fn load_session_cwd(cwd: Option<&str>, default_cwd: PathBuf) -> PathBuf { .unwrap_or(default_cwd) } +fn mesh_status_from_wire(status: MeshStatusDto) -> MeshStatusInfo { + MeshStatusInfo { + enabled: status.enabled, + peer_id: status.peer_id, + transport: status.transport, + known_peer_count: status.known_peer_count, + has_invite_store: status.has_invite_store, + has_mesh_state_store: status.has_mesh_state_store, + scopes: status + .scopes + .into_iter() + .map(mesh_scope_from_wire) + .collect(), + } +} + +fn mesh_scope_from_wire(scope: MeshScopeDto) -> MeshScopeInfo { + MeshScopeInfo { + kind: scope.kind, + id: scope.id, + } +} + +fn remote_node_from_wire(node: RemoteNodeDto) -> RemoteNodeInfo { + RemoteNodeInfo { + id: node.id, + label: node.label, + capabilities: node.capabilities, + active_sessions: node.active_sessions, + transport: node.transport, + last_seen_at: node.last_seen_at, + } +} + +fn mesh_nodes_from_wire(nodes: MeshNodesDto) -> MeshNodesInfo { + MeshNodesInfo { + nodes: nodes.nodes.into_iter().map(remote_node_from_wire).collect(), + } +} + +fn remote_session_from_wire(session: RemoteSessionDto) -> RemoteSessionInfo { + RemoteSessionInfo { + id: session.id, + node_id: session.node_id, + node_label: session.node_label, + title: session.title, + cwd: session.cwd, + updated_at: session.updated_at, + profile_id: session.profile_id, + model_id: session.model_id, + } +} + +fn remote_session_list_from_wire(list: RemoteSessionListDto) -> RemoteSessionListInfo { + RemoteSessionListInfo { + node_id: list.node_id, + sessions: list + .sessions + .into_iter() + .map(remote_session_from_wire) + .collect(), + next_offset: list.next_offset, + total_count: list.total_count, + } +} + +fn remote_session_attach_from_wire(attached: RemoteSessionAttachDto) -> RemoteSessionAttachInfo { + RemoteSessionAttachInfo { + session_id: attached.session_id, + node_id: attached.node_id, + attached: attached.attached, + config_options: attached.config_options, + snapshot: attached.snapshot, + } +} + +fn mesh_invite_created_from_wire(invite: MeshInviteCreatedDto) -> MeshInviteCreatedInfo { + MeshInviteCreatedInfo { + invite_id: invite.invite_id, + url: invite.url, + qr_code: invite.qr_code, + expires_at: invite.expires_at, + max_uses: invite.max_uses, + mesh_name: invite.mesh_name, + } +} + fn oauth_flow_from_wire(flow: OAuthFlowDto) -> OAuthFlow { OAuthFlow { flow_id: flow.flow_id, @@ -1623,9 +1729,9 @@ async fn post_connect_diagnostics( && let Ok(nodes_resp) = call_querymt_ext(connection, "querymt/mesh/nodes", json!({})).await && let Ok(nodes) = - serde_json::from_value::(ext_payload(&nodes_resp).clone()) + serde_json::from_value::(ext_payload(&nodes_resp).clone()) { - send_acp(srv_tx, AcpAppEvent::MeshNodes(nodes)); + send_acp(srv_tx, AcpAppEvent::MeshNodes(mesh_nodes_from_wire(nodes))); } if methods.iter().any(|m| m == "querymt/profiles") { match load_acp_profiles(connection).await { @@ -2927,6 +3033,106 @@ mod tests { )); } + #[test] + fn mesh_wire_adapters_preserve_every_semantic_field() { + let status = mesh_status_from_wire(MeshStatusDto { + enabled: true, + peer_id: Some("peer-1".into()), + transport: Some("webrtc".into()), + known_peer_count: 2, + has_invite_store: true, + has_mesh_state_store: true, + scopes: vec![MeshScopeDto { + kind: "team".into(), + id: "scope-1".into(), + }], + }); + assert!(status.enabled); + assert_eq!(status.peer_id.as_deref(), Some("peer-1")); + assert_eq!(status.transport.as_deref(), Some("webrtc")); + assert_eq!(status.known_peer_count, 2); + assert!(status.has_invite_store); + assert!(status.has_mesh_state_store); + assert_eq!(status.scopes[0].kind, "team"); + assert_eq!(status.scopes[0].id, "scope-1"); + + let nodes = mesh_nodes_from_wire(MeshNodesDto { + nodes: vec![RemoteNodeDto { + id: "node-1".into(), + label: "Framework".into(), + capabilities: vec!["sessions".into(), "attach".into()], + active_sessions: 3, + transport: "relay".into(), + last_seen_at: Some("2025-01-01T00:00:00Z".into()), + }], + }); + let node = &nodes.nodes[0]; + assert_eq!(node.id, "node-1"); + assert_eq!(node.label, "Framework"); + assert_eq!(node.capabilities, ["sessions", "attach"]); + assert_eq!(node.active_sessions, 3); + assert_eq!(node.transport, "relay"); + assert_eq!(node.last_seen_at.as_deref(), Some("2025-01-01T00:00:00Z")); + + let list = remote_session_list_from_wire(RemoteSessionListDto { + node_id: "node-1".into(), + sessions: vec![RemoteSessionDto { + id: "session-1".into(), + node_id: "node-1".into(), + node_label: Some("Framework".into()), + title: Some("Fix boundary".into()), + cwd: Some("/repo".into()), + updated_at: Some("now".into()), + profile_id: Some("profile-1".into()), + model_id: Some("model-1".into()), + }], + next_offset: Some(50), + total_count: 51, + }); + let session = &list.sessions[0]; + assert_eq!(list.node_id, "node-1"); + assert_eq!(list.next_offset, Some(50)); + assert_eq!(list.total_count, 51); + assert_eq!(session.id, "session-1"); + assert_eq!(session.node_id, "node-1"); + assert_eq!(session.node_label.as_deref(), Some("Framework")); + assert_eq!(session.title.as_deref(), Some("Fix boundary")); + assert_eq!(session.cwd.as_deref(), Some("/repo")); + assert_eq!(session.updated_at.as_deref(), Some("now")); + assert_eq!(session.profile_id.as_deref(), Some("profile-1")); + assert_eq!(session.model_id.as_deref(), Some("model-1")); + + let config_options = vec![json!({ "id": "profile", "value": { "nested": [1, 2] } })]; + let snapshot = json!({ "events": [{ "kind": "message" }] }); + let attached = remote_session_attach_from_wire(RemoteSessionAttachDto { + session_id: "session-1".into(), + node_id: "node-1".into(), + attached: true, + config_options: config_options.clone(), + snapshot: snapshot.clone(), + }); + assert_eq!(attached.session_id, "session-1"); + assert_eq!(attached.node_id, "node-1"); + assert!(attached.attached); + assert_eq!(attached.config_options, config_options); + assert_eq!(attached.snapshot, snapshot); + + let invite = mesh_invite_created_from_wire(MeshInviteCreatedDto { + invite_id: "invite-1".into(), + url: "qmt://mesh/join/token".into(), + qr_code: Some("QR".into()), + expires_at: 123, + max_uses: 2, + mesh_name: Some("Team".into()), + }); + assert_eq!(invite.invite_id, "invite-1"); + assert_eq!(invite.url, "qmt://mesh/join/token"); + assert_eq!(invite.qr_code.as_deref(), Some("QR")); + assert_eq!(invite.expires_at, 123); + assert_eq!(invite.max_uses, 2); + assert_eq!(invite.mesh_name.as_deref(), Some("Team")); + } + #[test] fn oauth_flow_from_wire_preserves_semantic_fields() { let flow = oauth_flow_from_wire(OAuthFlowDto { diff --git a/src/acp_state.rs b/src/acp_state.rs index fc13a41..3e959ab 100644 --- a/src/acp_state.rs +++ b/src/acp_state.rs @@ -13,6 +13,10 @@ use crate::domain::activity::{ use crate::domain::auth::{AuthProviderEntry, OAuthFlow, OAuthResult}; use crate::domain::chat::ChatEntry; use crate::domain::elicitation::ElicitationState; +use crate::domain::mesh::{ + MeshInviteCreatedInfo, MeshNodesInfo, MeshStatusInfo, RemoteSessionAttachInfo, + RemoteSessionListInfo, +}; use crate::domain::model::ModelEntry; use crate::domain::profile::{AgentInfo, ProfileInfo}; use crate::domain::session::{ @@ -20,10 +24,6 @@ use crate::domain::session::{ UndoStackSnapshot, UndoableTurn, }; use crate::domain::tool::ToolDetail; -use crate::protocol::{ - MeshInviteCreatedInfo, MeshNodesInfo, MeshStatusInfo, RemoteSessionAttachInfo, - RemoteSessionListInfo, -}; use crate::tool_detail; #[derive(Debug, Clone, Default)] @@ -313,11 +313,9 @@ impl crate::app::App { self.apply_remote_sessions(list); vec![] } - AcpAppEvent::RemoteSessionAttached(attached) => self.apply_remote_session_attached( - &attached.session_id, - &attached.node_id, - attached.attached, - ), + AcpAppEvent::RemoteSessionAttached(attached) => { + self.apply_remote_session_attached(attached) + } AcpAppEvent::SessionList { request, page } => { self.apply_acp_session_list(request, page) } @@ -2117,6 +2115,7 @@ mod tests { use super::*; use crate::app::{App, Screen}; use crate::domain::activity::{PendingDelegateToolCall, SessionOp}; + use crate::domain::mesh::{RemoteNodeInfo, RemoteSessionInfo}; use crate::domain::model::DelegateModelPreference; use crate::domain::session::{SessionListPage, UndoFrame, UndoFrameStatus, UndoState}; @@ -2497,7 +2496,7 @@ mod tests { let mut app = App::new(); let replies = app.handle_acp_event(AcpAppEvent::MeshNodes(MeshNodesInfo { - nodes: vec![crate::protocol::RemoteNodeInfo { + nodes: vec![RemoteNodeInfo { id: "node-1".into(), label: "framework".into(), active_sessions: 2, @@ -2514,6 +2513,37 @@ mod tests { )); } + #[test] + fn native_remote_sessions_store_values_and_clamp_cursor() { + let mut app = App::new(); + app.mesh_nodes = vec![RemoteNodeInfo { + id: "node-1".into(), + label: "Framework".into(), + ..Default::default() + }]; + app.remote_session_cursor = 4; + + let replies = app.handle_acp_event(AcpAppEvent::RemoteSessions(RemoteSessionListInfo { + node_id: "node-1".into(), + sessions: vec![RemoteSessionInfo { + id: "session-1".into(), + node_id: "node-1".into(), + title: Some("Boundary".into()), + ..Default::default() + }], + next_offset: Some(50), + total_count: 51, + })); + + assert!(replies.is_empty()); + assert_eq!(app.remote_session_cursor, 0); + assert_eq!(app.selected_remote_sessions()[0].id, "session-1"); + assert_eq!( + app.selected_remote_sessions()[0].title.as_deref(), + Some("Boundary") + ); + } + #[test] fn native_mesh_invite_created_stores_invite_and_opens_invite_view() { let mut app = App::new(); @@ -2558,6 +2588,49 @@ mod tests { )); } + #[test] + fn native_remote_attach_uses_current_cwd_and_detached_refreshes_sessions() { + let mut app = App::new(); + app.agent_id = Some("agent-1".into()); + app.launch_cwd = Some("/launch".into()); + + let attached = app.handle_acp_event(AcpAppEvent::RemoteSessionAttached( + RemoteSessionAttachInfo { + session_id: "remote-1".into(), + node_id: "node-1".into(), + attached: true, + config_options: vec![Value::String("retained but unused".into())], + snapshot: serde_json::json!({ "retained": true }), + }, + )); + assert!(matches!( + attached.as_slice(), + [ + Command::LoadSession { session_id, cwd: Some(cwd) }, + Command::SubscribeSession { session_id: subscribed_id, agent_id }, + ] if session_id == "remote-1" + && cwd == "/launch" + && subscribed_id == "remote-1" + && agent_id.as_deref() == Some("agent-1") + )); + + let detached = app.handle_acp_event(AcpAppEvent::RemoteSessionAttached( + RemoteSessionAttachInfo { + session_id: "remote-2".into(), + node_id: "node-2".into(), + attached: false, + config_options: Vec::new(), + snapshot: Value::Null, + }, + )); + assert_eq!(app.session_remote_node_id("remote-2"), Some("node-2")); + assert!(matches!( + detached.as_slice(), + [Command::ListRemoteSessions { node_id, offset: 0, limit: 50 }] + if node_id == "node-2" + )); + } + #[test] fn acp_discovery_hydrates_workspaces_and_replays_root_cursor() { let mut app = App::new(); diff --git a/src/app.rs b/src/app.rs index 4f9f7e4..40a85b6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -12,6 +12,9 @@ use crate::domain::activity::{ use crate::domain::auth::{AuthProviderEntry, OAuthFlow, OAuthResult}; use crate::domain::chat::{ChatEntry, format_outcome_labels}; use crate::domain::elicitation::ElicitationState; +use crate::domain::mesh::{ + MeshInviteCreatedInfo, MeshStatusInfo, RemoteNodeInfo, RemoteSessionInfo, +}; use crate::domain::model::{DelegateModelPreference, ModelEntry}; use crate::domain::profile::{AgentInfo, ProfileInfo}; use crate::domain::session::{ @@ -21,9 +24,7 @@ use crate::domain::session::{ use crate::highlight::Highlighter; use crate::markdown::CardBlock; use crate::mesh::{MeshFocus, MeshInviteFormField}; -use crate::protocol::{ - EventKind, MeshInviteCreatedInfo, MeshStatusInfo, RemoteNodeInfo, RemoteSessionInfo, -}; +use crate::protocol::EventKind; use crate::ui::{CardCache, ElicitationUiState}; /// Cache for rendered streaming markdown to avoid re-parsing every frame. diff --git a/src/domain.rs b/src/domain.rs index 1bef571..4a8cc22 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -2,6 +2,7 @@ pub(crate) mod activity; pub(crate) mod auth; pub(crate) mod chat; pub(crate) mod elicitation; +pub(crate) mod mesh; pub(crate) mod model; pub(crate) mod profile; pub(crate) mod session; diff --git a/src/domain/mesh.rs b/src/domain/mesh.rs new file mode 100644 index 0000000..92be2ef --- /dev/null +++ b/src/domain/mesh.rs @@ -0,0 +1,72 @@ +use serde_json::Value; + +#[derive(Debug, Clone, Default)] +pub struct MeshStatusInfo { + pub enabled: bool, + pub peer_id: Option, + pub transport: Option, + pub known_peer_count: u32, + pub has_invite_store: bool, + pub has_mesh_state_store: bool, + pub scopes: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct MeshScopeInfo { + pub kind: String, + pub id: String, +} + +#[derive(Debug, Clone, Default)] +pub struct RemoteNodeInfo { + pub id: String, + pub label: String, + pub capabilities: Vec, + pub active_sessions: u32, + pub transport: String, + pub last_seen_at: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct MeshNodesInfo { + pub nodes: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct RemoteSessionInfo { + pub id: String, + pub node_id: String, + pub node_label: Option, + pub title: Option, + pub cwd: Option, + pub updated_at: Option, + pub profile_id: Option, + pub model_id: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct RemoteSessionListInfo { + pub node_id: String, + pub sessions: Vec, + pub next_offset: Option, + pub total_count: u32, +} + +#[derive(Debug, Clone)] +pub struct RemoteSessionAttachInfo { + pub session_id: String, + pub node_id: String, + pub attached: bool, + pub config_options: Vec, + pub snapshot: Value, +} + +#[derive(Debug, Clone, Default)] +pub struct MeshInviteCreatedInfo { + pub invite_id: String, + pub url: String, + pub qr_code: Option, + pub expires_at: u64, + pub max_uses: u32, + pub mesh_name: Option, +} diff --git a/src/handlers.rs b/src/handlers.rs index ff3f9b4..1fe140f 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -2914,14 +2914,14 @@ mod model_popup_tests { let mut app = App::new(); app.popup = Popup::Mesh; app.mesh_focus = crate::mesh::MeshFocus::Sessions; - app.mesh_nodes = vec![crate::protocol::RemoteNodeInfo { + app.mesh_nodes = vec![crate::domain::mesh::RemoteNodeInfo { id: "node-1".into(), label: "framework".into(), ..Default::default() }]; app.remote_sessions_by_node.insert( "node-1".into(), - vec![crate::protocol::RemoteSessionInfo { + vec![crate::domain::mesh::RemoteSessionInfo { id: "remote-1".into(), node_id: "node-1".into(), title: Some("Fix bug".into()), @@ -2969,7 +2969,7 @@ mod model_popup_tests { fn mesh_invite_u_shows_manual_url_overlay() { let mut app = App::new(); app.open_mesh_invite_form(); - app.apply_mesh_invite_created(crate::protocol::MeshInviteCreatedInfo { + app.apply_mesh_invite_created(crate::domain::mesh::MeshInviteCreatedInfo { invite_id: "invite-1".into(), url: "qmt://mesh/join/token".into(), qr_code: Some("QR".into()), @@ -3035,7 +3035,7 @@ mod model_popup_tests { #[test] fn mesh_invite_qr_esc_returns_to_create_invite_popup() { let mut app = App::new(); - app.apply_mesh_invite_created(crate::protocol::MeshInviteCreatedInfo { + app.apply_mesh_invite_created(crate::domain::mesh::MeshInviteCreatedInfo { invite_id: "invite-1".into(), url: "qmt://mesh/join/token".into(), qr_code: Some("QR".into()), diff --git a/src/mesh.rs b/src/mesh.rs index a9dcfec..51ee39a 100644 --- a/src/mesh.rs +++ b/src/mesh.rs @@ -2,8 +2,9 @@ use std::time::{Duration, Instant}; use crate::app::{App, LogLevel, Popup}; use crate::command::Command; -use crate::protocol::{ - MeshInviteCreatedInfo, MeshNodesInfo, MeshStatusInfo, RemoteSessionInfo, RemoteSessionListInfo, +use crate::domain::mesh::{ + MeshInviteCreatedInfo, MeshNodesInfo, MeshStatusInfo, RemoteSessionAttachInfo, + RemoteSessionInfo, RemoteSessionListInfo, }; const INVITE_ERROR_TTL: Duration = Duration::from_secs(5); @@ -189,16 +190,14 @@ impl App { pub fn apply_remote_session_attached( &mut self, - session_id: &str, - node_id: &str, - attached: bool, + attached: RemoteSessionAttachInfo, ) -> Vec { - self.remember_remote_session_node(session_id, node_id); - if attached { + self.remember_remote_session_node(&attached.session_id, &attached.node_id); + if attached.attached { self.popup = Popup::None; self.set_status(LogLevel::Info, "mesh", "remote session attached"); Command::load_session_commands( - session_id.to_string(), + attached.session_id, self.current_session_cwd(), self.agent_id.clone(), ) @@ -206,7 +205,7 @@ impl App { } else { self.set_status(LogLevel::Info, "mesh", "remote session created"); vec![Command::ListRemoteSessions { - node_id: node_id.to_string(), + node_id: attached.node_id, offset: 0, limit: 50, }] diff --git a/src/protocol.rs b/src/protocol.rs index ef0e66a..7338dc3 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -301,7 +301,7 @@ pub struct DelegationData { } #[derive(Debug, Clone, Deserialize, Default)] -pub struct MeshStatusInfo { +pub struct MeshStatusDto { pub enabled: bool, #[serde(default)] pub peer_id: Option, @@ -314,17 +314,17 @@ pub struct MeshStatusInfo { #[serde(default)] pub has_mesh_state_store: bool, #[serde(default)] - pub scopes: Vec, + pub scopes: Vec, } #[derive(Debug, Clone, Deserialize, Default)] -pub struct MeshScopeInfo { +pub struct MeshScopeDto { pub kind: String, pub id: String, } #[derive(Debug, Clone, Deserialize, Default)] -pub struct RemoteNodeInfo { +pub struct RemoteNodeDto { pub id: String, pub label: String, #[serde(default)] @@ -338,13 +338,13 @@ pub struct RemoteNodeInfo { } #[derive(Debug, Clone, Deserialize, Default)] -pub struct MeshNodesInfo { +pub struct MeshNodesDto { #[serde(default)] - pub nodes: Vec, + pub nodes: Vec, } #[derive(Debug, Clone, Deserialize, Default)] -pub struct RemoteSessionInfo { +pub struct RemoteSessionDto { pub id: String, pub node_id: String, #[serde(default)] @@ -362,10 +362,10 @@ pub struct RemoteSessionInfo { } #[derive(Debug, Clone, Deserialize, Default)] -pub struct RemoteSessionListInfo { +pub struct RemoteSessionListDto { pub node_id: String, #[serde(default)] - pub sessions: Vec, + pub sessions: Vec, #[serde(default)] pub next_offset: Option, #[serde(default)] @@ -373,7 +373,7 @@ pub struct RemoteSessionListInfo { } #[derive(Debug, Clone, Deserialize)] -pub struct RemoteSessionAttachInfo { +pub struct RemoteSessionAttachDto { pub session_id: String, pub node_id: String, #[serde(default)] @@ -385,7 +385,7 @@ pub struct RemoteSessionAttachInfo { } #[derive(Debug, Clone, Deserialize, Default)] -pub struct MeshInviteCreatedInfo { +pub struct MeshInviteCreatedDto { pub invite_id: String, pub url: String, #[serde(default)] @@ -398,6 +398,141 @@ pub struct MeshInviteCreatedInfo { pub mesh_name: Option, } +#[cfg(test)] +mod mesh_dto_tests { + use super::{ + MeshInviteCreatedDto, MeshNodesDto, MeshStatusDto, RemoteNodeDto, RemoteSessionAttachDto, + RemoteSessionDto, RemoteSessionListDto, + }; + use serde_json::{Value, json}; + + #[test] + fn mesh_dtos_deserialize_representative_values_and_ignore_unknown_fields() { + let status: MeshStatusDto = serde_json::from_value(json!({ + "enabled": true, + "peer_id": "peer-1", + "transport": "webrtc", + "known_peer_count": 2, + "has_invite_store": true, + "has_mesh_state_store": true, + "scopes": [{ "kind": "team", "id": "scope-1" }], + "unknown": "ignored" + })) + .unwrap(); + assert!(status.enabled); + assert_eq!(status.scopes[0].kind, "team"); + assert_eq!(status.scopes[0].id, "scope-1"); + + let nodes: MeshNodesDto = serde_json::from_value(json!({ + "nodes": [{ + "id": "node-1", + "label": "Framework", + "capabilities": ["sessions"], + "active_sessions": 3, + "transport": "relay", + "last_seen_at": "2025-01-01T00:00:00Z" + }] + })) + .unwrap(); + assert_eq!(nodes.nodes[0].id, "node-1"); + assert_eq!(nodes.nodes[0].capabilities, ["sessions"]); + assert_eq!(nodes.nodes[0].active_sessions, 3); + + let node_defaults: RemoteNodeDto = serde_json::from_value(json!({ + "id": "node-2", + "label": "Defaults" + })) + .unwrap(); + assert!(node_defaults.capabilities.is_empty()); + assert_eq!(node_defaults.active_sessions, 0); + assert!(node_defaults.transport.is_empty()); + assert_eq!(node_defaults.last_seen_at, None); + + let sessions: RemoteSessionListDto = serde_json::from_value(json!({ + "node_id": "node-1", + "sessions": [{ + "id": "session-1", + "node_id": "node-1", + "node_label": "Framework", + "title": "Fix boundary", + "cwd": "/repo", + "updated_at": "now", + "profile_id": "profile-1", + "model_id": "model-1" + }], + "next_offset": 50, + "total_count": 51 + })) + .unwrap(); + assert_eq!(sessions.sessions[0].model_id.as_deref(), Some("model-1")); + assert_eq!(sessions.next_offset, Some(50)); + assert_eq!(sessions.total_count, 51); + + let session_defaults: RemoteSessionDto = serde_json::from_value(json!({ + "id": "session-2", + "node_id": "node-1" + })) + .unwrap(); + assert_eq!(session_defaults.title, None); + assert_eq!(session_defaults.cwd, None); + assert_eq!(session_defaults.model_id, None); + + let attach: RemoteSessionAttachDto = serde_json::from_value(json!({ + "session_id": "session-1", + "node_id": "node-1", + "attached": true, + "config_options": [{ "id": "profile", "value": { "nested": [1, 2] } }], + "snapshot": { "events": [{ "kind": "message" }] } + })) + .unwrap(); + assert_eq!(attach.config_options[0]["value"]["nested"], json!([1, 2])); + assert_eq!(attach.snapshot["events"][0]["kind"], "message"); + + let invite: MeshInviteCreatedDto = serde_json::from_value(json!({ + "invite_id": "invite-1", + "url": "qmt://mesh/join/token", + "qr_code": "QR", + "expires_at": 123, + "max_uses": 2, + "mesh_name": "Team" + })) + .unwrap(); + assert_eq!(invite.mesh_name.as_deref(), Some("Team")); + } + + #[test] + fn mesh_dto_defaults_preserve_existing_wire_behavior() { + let status: MeshStatusDto = serde_json::from_value(json!({ "enabled": false })).unwrap(); + assert!(status.scopes.is_empty()); + assert_eq!(status.known_peer_count, 0); + + let nodes: MeshNodesDto = serde_json::from_value(json!({})).unwrap(); + assert!(nodes.nodes.is_empty()); + + let sessions: RemoteSessionListDto = + serde_json::from_value(json!({ "node_id": "node-1" })).unwrap(); + assert!(sessions.sessions.is_empty()); + assert_eq!(sessions.next_offset, None); + assert_eq!(sessions.total_count, 0); + + let attach: RemoteSessionAttachDto = + serde_json::from_value(json!({ "session_id": "session-1", "node_id": "node-1" })) + .unwrap(); + assert!(!attach.attached); + assert!(attach.config_options.is_empty()); + assert_eq!(attach.snapshot, Value::Null); + + let invite: MeshInviteCreatedDto = serde_json::from_value(json!({ + "invite_id": "invite-1", + "url": "qmt://mesh/join/token" + })) + .unwrap(); + assert_eq!(invite.expires_at, 0); + assert_eq!(invite.max_uses, 0); + assert_eq!(invite.qr_code, None); + } +} + // ── Auth / token types ──────────────────────────────────────────────────────── #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 7d54d5f..773b140 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -2083,7 +2083,7 @@ mod tests { fn draw_mesh_popup_shows_nodes_sessions_and_hints() { let mut app = App::new(); app.popup = Popup::Mesh; - app.mesh_nodes = vec![crate::protocol::RemoteNodeInfo { + app.mesh_nodes = vec![crate::domain::mesh::RemoteNodeInfo { id: "node-1".into(), label: "framework".into(), active_sessions: 1, @@ -2091,7 +2091,7 @@ mod tests { }]; app.remote_sessions_by_node.insert( "node-1".into(), - vec![crate::protocol::RemoteSessionInfo { + vec![crate::domain::mesh::RemoteSessionInfo { id: "remote-1".into(), node_id: "node-1".into(), title: Some("Fix bug".into()), @@ -2132,7 +2132,7 @@ mod tests { fn draw_mesh_popup_shows_invite_url_and_qr() { let mut app = App::new(); app.popup = Popup::Mesh; - app.apply_mesh_invite_created(crate::protocol::MeshInviteCreatedInfo { + app.apply_mesh_invite_created(crate::domain::mesh::MeshInviteCreatedInfo { invite_id: "invite-1".into(), url: "qmt://mesh/join/token".into(), qr_code: Some("QR-LINE".into()),