aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-29 20:39:54 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-29 20:39:54 +0300
commit8b74fb8a12716269c76f0b503ee7db5bbec2c921 (patch)
treee5230f94cb1e6eb70d1f00c75cd85dae8b5b9ead
parent5fffc59cd8aaea1604abb90e749e58fd97acfac0 (diff)
feat: emit placement mutation events
-rw-r--r--docs/next/CHANGELOG.md3
-rw-r--r--docs/next/website/src/content/docs/socket-api.mdx19
-rw-r--r--src/api/schema/events.rs29
-rw-r--r--src/api/schema/response.rs3
-rw-r--r--src/api/schema/tests.rs75
-rw-r--r--src/api/subscriptions.rs8
-rw-r--r--src/app/actions.rs20
-rw-r--r--src/app/api/layouts.rs6
-rw-r--r--src/app/api/plugins/context.rs6
-rw-r--r--src/app/api/tabs.rs76
-rw-r--r--src/app/api/workspaces.rs90
-rw-r--r--src/protocol/wire.rs4
-rw-r--r--tests/api_ping.rs2
-rw-r--r--tests/cli_wrapper.rs12
-rw-r--r--tests/client_mode.rs25
-rw-r--r--tests/cross_area.rs18
-rw-r--r--tests/detach_reattach.rs58
-rw-r--r--tests/multi_client.rs4
-rw-r--r--tests/server_headless.rs22
-rw-r--r--tests/support/mod.rs1
20 files changed, 333 insertions, 148 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md
index b2b26f37..553f4044 100644
--- a/docs/next/CHANGELOG.md
+++ b/docs/next/CHANGELOG.md
@@ -2,6 +2,9 @@
## Unreleased
+### Changed
+- Bumped the client/server protocol version to 15 for socket API placement mutation event and response compatibility.
+
### Fixed
- Native Windows clients running inside Alacritty now preserve mouse reports and `ctrl+j` input instead of leaking mouse escape sequences into panes. `shift+enter` remains dependent on whether the outer terminal reports it as a distinct modified Enter key. (#792)
diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx
index 0db2e3c6..d651cc20 100644
--- a/docs/next/website/src/content/docs/socket-api.mdx
+++ b/docs/next/website/src/content/docs/socket-api.mdx
@@ -83,11 +83,11 @@ Raw socket method names use dot notation:
| Server | `ping`, `server.stop`, `server.reload_config`, `server.agent_manifests`, `server.reload_agent_manifests` |
| Notification | `notification.show` |
| Client | `client.window_title.set`, `client.window_title.clear` |
-| Workspace | `workspace.create`, `workspace.list`, `workspace.get`, `workspace.focus`, `workspace.rename`, `workspace.close` |
+| Workspace | `workspace.create`, `workspace.list`, `workspace.get`, `workspace.focus`, `workspace.rename`, `workspace.move`, `workspace.close` |
| Worktree | `worktree.list`, `worktree.create`, `worktree.open`, `worktree.remove` |
-| Tab | `tab.create`, `tab.list`, `tab.get`, `tab.focus`, `tab.rename`, `tab.close` |
+| Tab | `tab.create`, `tab.list`, `tab.get`, `tab.focus`, `tab.rename`, `tab.move`, `tab.close` |
| Pane | `pane.split`, `pane.swap`, `pane.move`, `pane.zoom`, `pane.layout`, `pane.process_info`, `pane.neighbor`, `pane.edges`, `pane.focus_direction`, `pane.resize`, `pane.list`, `pane.current`, `pane.get`, `pane.rename`, `pane.send_text`, `pane.send_keys`, `pane.send_input`, `pane.read`, `pane.report_agent`, `pane.report_agent_session`, `pane.report_metadata`, `pane.clear_agent_authority`, `pane.release_agent`, `pane.close`, `pane.wait_for_output` |
-| Layout | `layout.export`, `layout.apply` |
+| Layout | `layout.export`, `layout.apply`, `layout.set_split_ratio` |
| Agent | `agent.list`, `agent.get`, `agent.read`, `agent.explain`, `agent.send`, `agent.rename`, `agent.focus`, `agent.start` |
| Events | `events.subscribe`, `events.wait` |
| Integrations | `integration.install`, `integration.uninstall` |
@@ -177,6 +177,13 @@ not preserve live PTYs, scrollback, or running processes.
}
```
+`layout.set_split_ratio` updates an existing split in a tab layout. The response
+is `type: "layout_split_ratio_set"` with the updated portable `layout`.
+
+```json
+{"id":"req_ratio","method":"layout.set_split_ratio","params":{"tab_id":"w1:t1","path":[],"ratio":0.6}}
+```
+
Process-launching methods accept an `env` object. Herdr applies those key/value
pairs to the newly launched process only. Herdr also injects `HERDR_SOCKET_PATH`,
`HERDR_ENV=1`, `HERDR_WORKSPACE_ID`, `HERDR_TAB_ID`, and `HERDR_PANE_ID` into
@@ -587,7 +594,11 @@ Subscribe to events when you need a long-lived stream:
The first response acknowledges the subscription. Later lines are pushed events.
-Workspace event subscriptions include `workspace.created`, `workspace.updated`, `workspace.renamed`, `workspace.closed`, and `workspace.focused`. Workspace events describe Herdr UI/runtime lifecycle. `workspace.created` includes optional `workspace.worktree` provenance when the workspace belongs to a worktree group. `workspace.closed` includes a final `workspace` snapshot when Herdr can still identify it before removal.
+Workspace event subscriptions include `workspace.created`, `workspace.updated`, `workspace.renamed`, `workspace.moved`, `workspace.closed`, and `workspace.focused`. Workspace events describe Herdr UI/runtime lifecycle. `workspace.created` includes optional `workspace.worktree` provenance when the workspace belongs to a worktree group. `workspace.moved` includes the moved `workspace_id`, requested `insert_index`, and updated ordered `workspaces` list. `workspace.closed` includes a final `workspace` snapshot when Herdr can still identify it before removal.
+Tab event subscriptions include `tab.created`, `tab.closed`, `tab.focused`,
+`tab.renamed`, and `tab.moved`. `tab.moved` includes the moved `tab_id`,
+`workspace_id`, requested `insert_index`, and updated ordered `tabs` list for
+that workspace.
Pane event subscriptions include `pane.created`, `pane.closed`, `pane.focused`,
`pane.moved`, `pane.exited`, `pane.agent_detected`,
`pane.output_matched`, and `pane.agent_status_changed`.
diff --git a/src/api/schema/events.rs b/src/api/schema/events.rs
index 6e489180..014288b1 100644
--- a/src/api/schema/events.rs
+++ b/src/api/schema/events.rs
@@ -22,6 +22,8 @@ pub enum Subscription {
WorkspaceUpdated {},
#[serde(rename = "workspace.renamed")]
WorkspaceRenamed {},
+ #[serde(rename = "workspace.moved")]
+ WorkspaceMoved {},
#[serde(rename = "workspace.closed")]
WorkspaceClosed {},
#[serde(rename = "workspace.focused")]
@@ -40,6 +42,8 @@ pub enum Subscription {
TabFocused {},
#[serde(rename = "tab.renamed")]
TabRenamed {},
+ #[serde(rename = "tab.moved")]
+ TabMoved {},
#[serde(rename = "pane.created")]
PaneCreated {},
#[serde(rename = "pane.closed")]
@@ -115,6 +119,9 @@ pub enum EventMatch {
#[serde(default, skip_serializing_if = "Option::is_none")]
label: Option<String>,
},
+ WorkspaceMoved {
+ workspace_id: String,
+ },
WorkspaceFocused {
workspace_id: String,
},
@@ -132,6 +139,9 @@ pub enum EventMatch {
#[serde(default, skip_serializing_if = "Option::is_none")]
label: Option<String>,
},
+ TabMoved {
+ tab_id: String,
+ },
TabFocused {
tab_id: String,
},
@@ -176,6 +186,7 @@ pub enum EventKind {
WorkspaceUpdated,
WorkspaceClosed,
WorkspaceRenamed,
+ WorkspaceMoved,
WorkspaceFocused,
WorktreeCreated,
WorktreeOpened,
@@ -183,6 +194,7 @@ pub enum EventKind {
TabCreated,
TabClosed,
TabRenamed,
+ TabMoved,
TabFocused,
PaneCreated,
PaneClosed,
@@ -201,6 +213,7 @@ impl EventKind {
EventKind::WorkspaceUpdated => "workspace.updated",
EventKind::WorkspaceClosed => "workspace.closed",
EventKind::WorkspaceRenamed => "workspace.renamed",
+ EventKind::WorkspaceMoved => "workspace.moved",
EventKind::WorkspaceFocused => "workspace.focused",
EventKind::WorktreeCreated => "worktree.created",
EventKind::WorktreeOpened => "worktree.opened",
@@ -208,6 +221,7 @@ impl EventKind {
EventKind::TabCreated => "tab.created",
EventKind::TabClosed => "tab.closed",
EventKind::TabRenamed => "tab.renamed",
+ EventKind::TabMoved => "tab.moved",
EventKind::TabFocused => "tab.focused",
EventKind::PaneCreated => "pane.created",
EventKind::PaneClosed => "pane.closed",
@@ -227,6 +241,7 @@ pub const KNOWN_EVENT_KINDS: &[EventKind] = &[
EventKind::WorkspaceUpdated,
EventKind::WorkspaceClosed,
EventKind::WorkspaceRenamed,
+ EventKind::WorkspaceMoved,
EventKind::WorkspaceFocused,
EventKind::WorktreeCreated,
EventKind::WorktreeOpened,
@@ -234,6 +249,7 @@ pub const KNOWN_EVENT_KINDS: &[EventKind] = &[
EventKind::TabCreated,
EventKind::TabClosed,
EventKind::TabRenamed,
+ EventKind::TabMoved,
EventKind::TabFocused,
EventKind::PaneCreated,
EventKind::PaneClosed,
@@ -250,6 +266,7 @@ pub const PLUGIN_HOOK_EVENT_KINDS: &[EventKind] = &[
EventKind::WorkspaceUpdated,
EventKind::WorkspaceClosed,
EventKind::WorkspaceRenamed,
+ EventKind::WorkspaceMoved,
EventKind::WorkspaceFocused,
EventKind::WorktreeCreated,
EventKind::WorktreeOpened,
@@ -257,6 +274,7 @@ pub const PLUGIN_HOOK_EVENT_KINDS: &[EventKind] = &[
EventKind::TabCreated,
EventKind::TabClosed,
EventKind::TabRenamed,
+ EventKind::TabMoved,
EventKind::TabFocused,
EventKind::PaneCreated,
EventKind::PaneClosed,
@@ -383,6 +401,11 @@ pub enum EventData {
workspace_id: String,
label: String,
},
+ WorkspaceMoved {
+ workspace_id: String,
+ insert_index: usize,
+ workspaces: Vec<WorkspaceInfo>,
+ },
WorkspaceFocused {
workspace_id: String,
},
@@ -414,6 +437,12 @@ pub enum EventData {
workspace_id: String,
label: String,
},
+ TabMoved {
+ tab_id: String,
+ workspace_id: String,
+ insert_index: usize,
+ tabs: Vec<TabInfo>,
+ },
TabFocused {
tab_id: String,
workspace_id: String,
diff --git a/src/api/schema/response.rs b/src/api/schema/response.rs
index 51ea0d36..57ab215b 100644
--- a/src/api/schema/response.rs
+++ b/src/api/schema/response.rs
@@ -130,6 +130,9 @@ pub enum ResponseResult {
LayoutApply {
layout: LayoutDescription,
},
+ LayoutSplitRatioSet {
+ layout: LayoutDescription,
+ },
PaneNeighbor {
neighbor: PaneNeighborResult,
},
diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs
index d9ebe30b..dd2004cf 100644
--- a/src/api/schema/tests.rs
+++ b/src/api/schema/tests.rs
@@ -250,18 +250,39 @@ fn pane_process_info_request_round_trips() {
#[test]
fn event_envelope_round_trips() {
- let event = EventEnvelope {
- event: EventKind::PaneOutputChanged,
- data: EventData::PaneOutputChanged {
- pane_id: "p_1".into(),
- workspace_id: "w_1".into(),
- revision: 42,
+ let events = [
+ EventEnvelope {
+ event: EventKind::PaneOutputChanged,
+ data: EventData::PaneOutputChanged {
+ pane_id: "p_1".into(),
+ workspace_id: "w_1".into(),
+ revision: 42,
+ },
},
- };
+ EventEnvelope {
+ event: EventKind::WorkspaceMoved,
+ data: EventData::WorkspaceMoved {
+ workspace_id: "w_1".into(),
+ insert_index: 2,
+ workspaces: vec![],
+ },
+ },
+ EventEnvelope {
+ event: EventKind::TabMoved,
+ data: EventData::TabMoved {
+ tab_id: "w_1:1".into(),
+ workspace_id: "w_1".into(),
+ insert_index: 1,
+ tabs: vec![],
+ },
+ },
+ ];
- let json = serde_json::to_string(&event).unwrap();
- let restored: EventEnvelope = serde_json::from_str(&json).unwrap();
- assert_eq!(restored, event);
+ for event in events {
+ let json = serde_json::to_string(&event).unwrap();
+ let restored: EventEnvelope = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, event);
+ }
}
#[test]
@@ -700,6 +721,28 @@ fn layout_export_apply_round_trip() {
let json = serde_json::to_string(&response).unwrap();
let restored: SuccessResponse = serde_json::from_str(&json).unwrap();
assert_eq!(restored, response);
+
+ let response = SuccessResponse {
+ id: "layout_ratio".into(),
+ result: ResponseResult::LayoutSplitRatioSet {
+ layout: LayoutDescription {
+ workspace_id: "w1".into(),
+ tab_id: "w1:1".into(),
+ zoomed: false,
+ focused_pane_id: "w1-1".into(),
+ root: LayoutNode::Pane {
+ pane: LayoutPane {
+ pane_id: Some("w1-1".into()),
+ ..Default::default()
+ },
+ },
+ },
+ },
+ };
+ let json = serde_json::to_string(&response).unwrap();
+ assert!(json.contains("\"type\":\"layout_split_ratio_set\""));
+ let restored: SuccessResponse = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, response);
}
#[test]
@@ -752,6 +795,18 @@ fn authority_mutation_requests_round_trip() {
assert_eq!(json["method"], "layout.set_split_ratio");
let restored: Request = serde_json::from_value(json).unwrap();
assert_eq!(restored, split_ratio);
+
+ let subscription = Request {
+ id: "sub_moves".into(),
+ method: Method::EventsSubscribe(EventsSubscribeParams {
+ subscriptions: vec![Subscription::WorkspaceMoved {}, Subscription::TabMoved {}],
+ }),
+ };
+ let json = serde_json::to_string(&subscription).unwrap();
+ assert!(json.contains("\"type\":\"workspace.moved\""));
+ assert!(json.contains("\"type\":\"tab.moved\""));
+ let restored: Request = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, subscription);
}
#[test]
diff --git a/src/api/subscriptions.rs b/src/api/subscriptions.rs
index d71ea24d..f3b82090 100644
--- a/src/api/subscriptions.rs
+++ b/src/api/subscriptions.rs
@@ -120,6 +120,10 @@ impl ActiveSubscription {
event_kind: crate::api::schema::EventKind::WorkspaceRenamed,
last_sequence: 0,
})),
+ Subscription::WorkspaceMoved {} => Ok(Self::Event(ActiveEventSubscription {
+ event_kind: crate::api::schema::EventKind::WorkspaceMoved,
+ last_sequence: 0,
+ })),
Subscription::WorkspaceClosed {} => Ok(Self::Event(ActiveEventSubscription {
event_kind: crate::api::schema::EventKind::WorkspaceClosed,
last_sequence: 0,
@@ -156,6 +160,10 @@ impl ActiveSubscription {
event_kind: crate::api::schema::EventKind::TabRenamed,
last_sequence: 0,
})),
+ Subscription::TabMoved {} => Ok(Self::Event(ActiveEventSubscription {
+ event_kind: crate::api::schema::EventKind::TabMoved,
+ last_sequence: 0,
+ })),
Subscription::PaneCreated {} => Ok(Self::Event(ActiveEventSubscription {
event_kind: crate::api::schema::EventKind::PaneCreated,
last_sequence: 0,
diff --git a/src/app/actions.rs b/src/app/actions.rs
index d791da03..eccc8d4d 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -1193,9 +1193,18 @@ impl AppState {
self.switch_workspace(prev);
}
- pub fn move_workspace(&mut self, source_idx: usize, insert_idx: usize) {
+ pub fn move_workspace(&mut self, source_idx: usize, insert_idx: usize) -> bool {
if source_idx >= self.workspaces.len() || insert_idx > self.workspaces.len() {
- return;
+ return false;
+ }
+
+ let target_idx = if source_idx < insert_idx {
+ insert_idx - 1
+ } else {
+ insert_idx
+ };
+ if source_idx == target_idx {
+ return false;
}
self.mark_session_dirty();
@@ -1207,12 +1216,6 @@ impl AppState {
.map(|workspace| workspace.id.clone());
let workspace = self.workspaces.remove(source_idx);
- let target_idx = if source_idx < insert_idx {
- insert_idx.saturating_sub(1)
- } else {
- insert_idx
- }
- .min(self.workspaces.len());
self.workspaces.insert(target_idx, workspace);
self.active = active_id.and_then(|id| self.workspaces.iter().position(|ws| ws.id == id));
@@ -1220,6 +1223,7 @@ impl AppState {
.and_then(|id| self.workspaces.iter().position(|ws| ws.id == id))
.unwrap_or(0);
self.ensure_workspace_visible(self.selected);
+ true
}
pub fn scroll_tabs_left(&mut self) {
diff --git a/src/app/api/layouts.rs b/src/app/api/layouts.rs
index 709d9f39..c7c02f5f 100644
--- a/src/app/api/layouts.rs
+++ b/src/app/api/layouts.rs
@@ -243,7 +243,7 @@ impl App {
let Some(layout) = self.layout_description(ws_idx, tab_idx) else {
return encode_error(id, "layout_not_found", "layout unavailable");
};
- encode_success(id, ResponseResult::LayoutApply { layout })
+ encode_success(id, ResponseResult::LayoutSplitRatioSet { layout })
}
fn resolve_layout_export_target(&self, params: &LayoutExportParams) -> Option<(usize, usize)> {
@@ -684,8 +684,8 @@ mod tests {
);
let success: SuccessResponse = serde_json::from_str(&response).unwrap();
- let ResponseResult::LayoutApply { layout } = success.result else {
- panic!("expected layout apply response");
+ let ResponseResult::LayoutSplitRatioSet { layout } = success.result else {
+ panic!("expected layout split ratio set response");
};
let LayoutNode::Split { ratio, .. } = layout.root else {
panic!("expected split layout root");
diff --git a/src/app/api/plugins/context.rs b/src/app/api/plugins/context.rs
index 92d0d4c6..e81a74d3 100644
--- a/src/app/api/plugins/context.rs
+++ b/src/app/api/plugins/context.rs
@@ -63,6 +63,7 @@ impl App {
})
}),
EventData::WorkspaceRenamed { workspace_id, .. }
+ | EventData::WorkspaceMoved { workspace_id, .. }
| EventData::WorkspaceFocused { workspace_id } => self
.plugin_context_for_workspace_id(workspace_id, correlation_id)
.unwrap_or_else(|| {
@@ -103,6 +104,11 @@ impl App {
workspace_id,
..
}
+ | EventData::TabMoved {
+ tab_id,
+ workspace_id,
+ ..
+ }
| EventData::TabFocused {
tab_id,
workspace_id,
diff --git a/src/app/api/tabs.rs b/src/app/api/tabs.rs
index 1ffcafdd..abd05b29 100644
--- a/src/app/api/tabs.rs
+++ b/src/app/api/tabs.rs
@@ -14,12 +14,10 @@ impl App {
let Some(ws_idx) = self.parse_workspace_id(&workspace_id) else {
return workspace_not_found(id, &workspace_id);
};
- let Some(ws) = self.state.workspaces.get(ws_idx) else {
+ let Some(_) = self.state.workspaces.get(ws_idx) else {
return workspace_not_found(id, &workspace_id);
};
- (0..ws.tabs.len())
- .filter_map(|tab_idx| self.tab_info(ws_idx, tab_idx))
- .collect()
+ self.tab_list_info(ws_idx)
} else {
let mut tabs = Vec::new();
for (ws_idx, ws) in self.state.workspaces.iter().enumerate() {
@@ -190,34 +188,35 @@ impl App {
);
}
+ let tab_id = self
+ .public_tab_id(ws_idx, tab_idx)
+ .unwrap_or_else(|| crate::workspace::public_tab_id_for_number(&ws.id, tab_idx + 1));
+ let workspace_id = self.public_workspace_id(ws_idx);
+ let insert_index = params.insert_index;
let moved = self
.state
.workspaces
.get_mut(ws_idx)
- .is_some_and(|ws| ws.move_tab(tab_idx, params.insert_index));
+ .is_some_and(|ws| ws.move_tab(tab_idx, insert_index));
+ let tabs = self.tab_list_info(ws_idx);
if moved {
self.schedule_session_save();
if self.state.active == Some(ws_idx) {
self.state.tab_scroll_follow_active = true;
self.state.refresh_tab_bar_view();
}
+ self.emit_event(EventEnvelope {
+ event: EventKind::TabMoved,
+ data: EventData::TabMoved {
+ tab_id,
+ workspace_id,
+ insert_index,
+ tabs: tabs.clone(),
+ },
+ });
}
- encode_success(
- id,
- ResponseResult::TabList {
- tabs: self
- .state
- .workspaces
- .get(ws_idx)
- .map(|ws| {
- (0..ws.tabs.len())
- .filter_map(|idx| self.tab_info(ws_idx, idx))
- .collect()
- })
- .unwrap_or_default(),
- },
- )
+ encode_success(id, ResponseResult::TabList { tabs })
}
pub(super) fn handle_tab_close(&mut self, id: String, target: TabTarget) -> String {
@@ -269,6 +268,18 @@ impl App {
encode_success(id, ResponseResult::Ok {})
}
+
+ fn tab_list_info(&self, ws_idx: usize) -> Vec<crate::api::schema::TabInfo> {
+ self.state
+ .workspaces
+ .get(ws_idx)
+ .map(|ws| {
+ (0..ws.tabs.len())
+ .filter_map(|idx| self.tab_info(ws_idx, idx))
+ .collect()
+ })
+ .unwrap_or_default()
+ }
}
fn workspace_not_found(id: String, workspace_id: &str) -> String {
@@ -290,14 +301,9 @@ mod tests {
#[test]
fn api_tab_move_reorders_tabs_in_target_workspace() {
+ let event_hub = crate::api::EventHub::default();
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
- let mut app = App::new(
- &Config::default(),
- true,
- None,
- api_rx,
- crate::api::EventHub::default(),
- );
+ let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone());
let mut workspace = Workspace::test_new("tabs");
workspace.test_add_tab(Some("two"));
workspace.test_add_tab(Some("three"));
@@ -310,7 +316,7 @@ mod tests {
let response = app.handle_tab_move(
"req".into(),
TabMoveParams {
- tab_id: moved_id,
+ tab_id: moved_id.clone(),
insert_index: 3,
},
);
@@ -321,5 +327,19 @@ mod tests {
};
assert_eq!(app.state.workspaces[0].tabs[2].root_pane, moved_root);
assert_eq!(tabs[2].tab_id, app.public_tab_id(0, 2).unwrap());
+ let events = event_hub.events_after(0);
+ assert!(events.iter().any(|(_, event)| {
+ matches!(
+ &event.data,
+ EventData::TabMoved {
+ tab_id,
+ workspace_id,
+ insert_index: 3,
+ tabs,
+ } if tab_id == &moved_id
+ && workspace_id == &app.public_workspace_id(0)
+ && tabs[2].tab_id == moved_id
+ )
+ }));
}
}
diff --git a/src/app/api/workspaces.rs b/src/app/api/workspaces.rs
index 56427463..f4189a04 100644
--- a/src/app/api/workspaces.rs
+++ b/src/app/api/workspaces.rs
@@ -13,13 +13,7 @@ impl App {
encode_success(
id,
ResponseResult::WorkspaceList {
- workspaces: self
- .state
- .workspaces
- .iter()
- .enumerate()
- .map(|(idx, _)| self.workspace_info(idx))
- .collect(),
+ workspaces: self.workspace_list_info(),
},
)
}
@@ -140,20 +134,22 @@ impl App {
);
}
- self.state.move_workspace(index, params.insert_index);
+ let workspace_id = self.public_workspace_id(index);
+ let insert_index = params.insert_index;
+ let moved = self.state.move_workspace(index, insert_index);
+ let workspaces = self.workspace_list_info();
+ if moved {
+ self.emit_event(EventEnvelope {
+ event: EventKind::WorkspaceMoved,
+ data: EventData::WorkspaceMoved {
+ workspace_id,
+ insert_index,
+ workspaces: workspaces.clone(),
+ },
+ });
+ }
- encode_success(
- id,
- ResponseResult::WorkspaceList {
- workspaces: self
- .state
- .workspaces
- .iter()
- .enumerate()
- .map(|(idx, _)| self.workspace_info(idx))
- .collect(),
- },
- )
+ encode_success(id, ResponseResult::WorkspaceList { workspaces })
}
pub(super) fn handle_workspace_close(&mut self, id: String, target: WorkspaceTarget) -> String {
@@ -192,6 +188,15 @@ impl App {
encode_success(id, ResponseResult::Ok {})
}
+
+ fn workspace_list_info(&self) -> Vec<crate::api::schema::WorkspaceInfo> {
+ self.state
+ .workspaces
+ .iter()
+ .enumerate()
+ .map(|(idx, _)| self.workspace_info(idx))
+ .collect()
+ }
}
fn workspace_not_found(id: String, workspace_id: &str) -> String {
@@ -279,14 +284,9 @@ mod tests {
#[test]
fn api_workspace_move_reorders_workspaces() {
+ let event_hub = crate::api::EventHub::default();
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
- let mut app = App::new(
- &Config::default(),
- true,
- None,
- api_rx,
- crate::api::EventHub::default(),
- );
+ let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone());
app.state.workspaces = vec![
Workspace::test_new("one"),
Workspace::test_new("two"),
@@ -310,5 +310,41 @@ mod tests {
};
assert_eq!(workspaces[2].workspace_id, moved_id);
assert_eq!(app.state.workspaces[2].display_name(), "one");
+ let events = event_hub.events_after(0);
+ assert!(events.iter().any(|(_, event)| {
+ matches!(
+ &event.data,
+ EventData::WorkspaceMoved {
+ workspace_id,
+ insert_index: 3,
+ workspaces,
+ } if workspace_id == &moved_id
+ && workspaces[2].workspace_id == moved_id
+ )
+ }));
+ }
+
+ #[test]
+ fn api_workspace_move_noop_does_not_emit_event() {
+ let event_hub = crate::api::EventHub::default();
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone());
+ app.state.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")];
+ let moved_id = app.public_workspace_id(0);
+
+ let response = app.handle_workspace_move(
+ "req".into(),
+ WorkspaceMoveParams {
+ workspace_id: moved_id.clone(),
+ insert_index: 1,
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::WorkspaceList { workspaces } = success.result else {
+ panic!("expected workspace list");
+ };
+ assert_eq!(workspaces[0].workspace_id, moved_id);
+ assert!(event_hub.events_after(0).is_empty());
}
}
diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs
index 1bbfd9a8..ac21df19 100644
--- a/src/protocol/wire.rs
+++ b/src/protocol/wire.rs
@@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
/// Current protocol version. Bumped when wire format changes incompatibly.
-pub const PROTOCOL_VERSION: u32 = 14;
+pub const PROTOCOL_VERSION: u32 = 15;
/// Maximum allowed frame payload size (2 MB). Frames larger than this are
/// rejected to prevent denial-of-service via oversized length prefixes.
@@ -935,7 +935,7 @@ mod tests {
}
#[test]
- fn client_message_wire_tags_preserve_protocol_14_order() {
+ fn client_message_wire_tags_preserve_protocol_15_order() {
fn tag(msg: &ClientMessage) -> u8 {
*bincode::serde::encode_to_vec(msg, bincode::config::standard())
.unwrap()
diff --git a/tests/api_ping.rs b/tests/api_ping.rs
index 1f9fafe6..1d625637 100644
--- a/tests/api_ping.rs
+++ b/tests/api_ping.rs
@@ -304,7 +304,7 @@ fn ping_over_socket_returns_version() {
assert_eq!(value["result"]["version"], env!("CARGO_PKG_VERSION"));
// Intentionally hardcoded so wire protocol bumps require updating this test.
// Changing this value means old clients/servers are no longer compatible.
- assert_eq!(value["result"]["protocol"], 14);
+ assert_eq!(value["result"]["protocol"], 15);
cleanup_spawned_herdr(child, base);
}
diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs
index 55555be5..542d9768 100644
--- a/tests/cli_wrapper.rs
+++ b/tests/cli_wrapper.rs
@@ -1441,7 +1441,7 @@ fn status_commands_report_client_and_server_versions() {
"stdout: {full_stdout}"
);
assert!(
- full_stdout.contains(" protocol: 14"),
+ full_stdout.contains(" protocol: 15"),
"stdout: {full_stdout}"
);
assert!(full_stdout.contains("server:\n"), "stdout: {full_stdout}");
@@ -1474,7 +1474,7 @@ fn status_commands_report_client_and_server_versions() {
"stdout: {server_stdout}"
);
assert!(
- server_stdout.contains("protocol: 14"),
+ server_stdout.contains("protocol: 15"),
"stdout: {server_stdout}"
);
@@ -1486,7 +1486,7 @@ fn status_commands_report_client_and_server_versions() {
"stdout: {client_stdout}"
);
assert!(
- client_stdout.contains("protocol: 14"),
+ client_stdout.contains("protocol: 15"),
"stdout: {client_stdout}"
);
assert!(
@@ -1496,7 +1496,7 @@ fn status_commands_report_client_and_server_versions() {
let full_json = run_cli_json(&socket_path, &["status", "--json"]);
assert_eq!(full_json["client"]["version"], env!("CARGO_PKG_VERSION"));
- assert_eq!(full_json["client"]["protocol"], 14);
+ assert_eq!(full_json["client"]["protocol"], 15);
assert_eq!(full_json["server"]["status"], "running");
assert_eq!(full_json["server"]["running"], true);
assert_eq!(full_json["server"]["compatible"], true);
@@ -1510,12 +1510,12 @@ fn status_commands_report_client_and_server_versions() {
let server_json = run_cli_json(&socket_path, &["status", "server", "--json"]);
assert_eq!(server_json["status"], "running");
assert_eq!(server_json["version"], env!("CARGO_PKG_VERSION"));
- assert_eq!(server_json["protocol"], 14);
+ assert_eq!(server_json["protocol"], 15);
assert_eq!(server_json["compatible"], true);
let client_json = run_cli_json(&socket_path, &["status", "client", "--json"]);
assert_eq!(client_json["version"], env!("CARGO_PKG_VERSION"));
- assert_eq!(client_json["protocol"], 14);
+ assert_eq!(client_json["protocol"], 15);
assert!(client_json["binary"]
.as_str()
.is_some_and(|path| !path.is_empty()));
diff --git a/tests/client_mode.rs b/tests/client_mode.rs
index 4f628faa..c1b62394 100644
--- a/tests/client_mode.rs
+++ b/tests/client_mode.rs
@@ -16,7 +16,7 @@ use serde_json::Value;
use support::{
cleanup_test_base, client_handshake, encode_varint_u32, frame_message, read_server_message,
register_runtime_dir, register_spawned_herdr_pid, unregister_spawned_herdr_pid,
- wait_for_message_variant, wait_for_socket, wait_until,
+ wait_for_message_variant, wait_for_socket, wait_until, CURRENT_PROTOCOL,
};
fn unique_test_dir() -> PathBuf {
@@ -311,8 +311,11 @@ fn client_connects_and_receives_frame() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 14, "server should report protocol version 14");
+ client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed");
+ assert_eq!(
+ version, CURRENT_PROTOCOL,
+ "server should report current protocol version"
+ );
assert!(
error.is_none(),
"handshake should not have error: {:?}",
@@ -379,8 +382,8 @@ fn client_sees_headless_startup_config_diagnostic() {
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 14);
+ client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, CURRENT_PROTOCOL);
assert!(error.is_none(), "{:?}", error);
stream
@@ -595,8 +598,8 @@ fn client_receives_frame_after_pane_output() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 14);
+ client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, CURRENT_PROTOCOL);
assert!(error.is_none(), "{:?}", error);
read_next_frame_payload(&mut stream, Duration::from_secs(10))
@@ -710,8 +713,8 @@ fn graceful_shutdown_sends_server_shutdown_to_client() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 14);
+ client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, CURRENT_PROTOCOL);
assert!(error.is_none(), "{:?}", error);
// Drain initial frame(s).
@@ -809,8 +812,8 @@ fn client_receives_notify_on_agent_state_change() {
// Connect as a client and perform handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect");
let (version, error) =
- client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 14);
+ client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, CURRENT_PROTOCOL);
assert!(error.is_none(), "{:?}", error);
// Drain initial frame(s).
diff --git a/tests/cross_area.rs b/tests/cross_area.rs
index d596a8fc..9fe71790 100644
--- a/tests/cross_area.rs
+++ b/tests/cross_area.rs
@@ -15,7 +15,7 @@ use serde::Deserialize;
use serde_json::{json, Value};
use support::{
cleanup_test_base, register_runtime_dir, register_spawned_herdr_pid,
- unregister_spawned_herdr_pid,
+ unregister_spawned_herdr_pid, CURRENT_PROTOCOL,
};
fn unique_test_dir() -> PathBuf {
@@ -687,7 +687,7 @@ fn cross_area_detach_and_reattach_preserves_state() {
// Local attach (client A).
let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect");
- client_handshake(&mut client_a, 14, 100, 30);
+ client_handshake(&mut client_a, CURRENT_PROTOCOL, 100, 30);
assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
// Use herdr: create a workspace and write output into its pane.
@@ -724,7 +724,7 @@ fn cross_area_detach_and_reattach_preserves_state() {
// Reattach from another terminal/session (client B).
let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect");
- client_handshake(&mut client_b, 14, 80, 24);
+ client_handshake(&mut client_b, CURRENT_PROTOCOL, 80, 24);
assert!(
wait_for_frame(&mut client_b, Duration::from_secs(5)),
"reattached client should receive frame"
@@ -780,7 +780,7 @@ fn cross_area_agent_process_survives_detach_and_reattach() {
wait_for_socket(&client_socket, Duration::from_secs(10));
let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect");
- client_handshake(&mut client_a, 14, 100, 30);
+ client_handshake(&mut client_a, CURRENT_PROTOCOL, 100, 30);
assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
let created = workspace_create(&api_socket, "agent-persist");
@@ -833,7 +833,7 @@ fn cross_area_agent_process_survives_detach_and_reattach() {
// Reattach and ensure client-side state reflects the persisted working status.
let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect");
- client_handshake(&mut client_b, 14, 80, 24);
+ client_handshake(&mut client_b, CURRENT_PROTOCOL, 80, 24);
let saw_working_on_client =
wait_for_frame_matching(&mut client_b, Duration::from_secs(5), |frame| {
frame_contains_text(frame, "working")
@@ -878,7 +878,7 @@ fn cross_area_client_and_api_workspace_views_are_consistent() {
wait_for_socket(&client_socket, Duration::from_secs(10));
let mut client = UnixStream::connect(&client_socket).expect("client should connect");
- client_handshake(&mut client, 14, 100, 30);
+ client_handshake(&mut client, CURRENT_PROTOCOL, 100, 30);
assert!(wait_for_frame(&mut client, Duration::from_secs(2)));
drain_server_messages(&mut client, Duration::from_millis(300));
@@ -941,9 +941,9 @@ fn cross_area_two_clients_shared_view_and_single_detach_stability() {
wait_for_socket(&client_socket, Duration::from_secs(10));
let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect");
- client_handshake(&mut client_a, 14, 110, 30);
+ client_handshake(&mut client_a, CURRENT_PROTOCOL, 110, 30);
let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect");
- client_handshake(&mut client_b, 14, 100, 30);
+ client_handshake(&mut client_b, CURRENT_PROTOCOL, 100, 30);
assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
assert!(wait_for_frame(&mut client_b, Duration::from_secs(2)));
@@ -1112,7 +1112,7 @@ fn cross_area_server_kill_then_restart_and_reconnect() {
let mut reconnect_client =
UnixStream::connect(&client_socket).expect("new client should connect after restart");
- client_handshake(&mut reconnect_client, 14, 80, 24);
+ client_handshake(&mut reconnect_client, CURRENT_PROTOCOL, 80, 24);
assert!(
wait_for_frame(&mut reconnect_client, Duration::from_secs(5)),
"new client should receive frame after restart"
diff --git a/tests/detach_reattach.rs b/tests/detach_reattach.rs
index cfcb5b89..3abe1bec 100644
--- a/tests/detach_reattach.rs
+++ b/tests/detach_reattach.rs
@@ -16,7 +16,7 @@ use serde_json::Value;
use support::{
cleanup_test_base, client_handshake, drain_messages, read_server_message, register_runtime_dir,
register_spawned_herdr_pid, send_detach, send_input, unregister_spawned_herdr_pid,
- wait_for_disconnect, wait_for_message_variant, wait_for_socket, wait_until,
+ wait_for_disconnect, wait_for_message_variant, wait_for_socket, wait_until, CURRENT_PROTOCOL,
};
fn unique_test_dir() -> PathBuf {
@@ -276,8 +276,8 @@ fn navigate_q_detaches_client_and_server_persists() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 14);
+ client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, CURRENT_PROTOCOL);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -338,8 +338,8 @@ fn explicit_detach_message_causes_clean_disconnect() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect");
let (version, error) =
- client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 14);
+ client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, CURRENT_PROTOCOL);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -396,9 +396,9 @@ fn reattach_after_detach_shows_current_state() {
// --- Client A ---
let mut stream_a = UnixStream::connect(&client_socket).expect("client A should connect");
- let (version, error) =
- client_handshake(&mut stream_a, 14, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 14);
+ let (version, error) = client_handshake(&mut stream_a, CURRENT_PROTOCOL, 80, 24)
+ .expect("handshake should succeed");
+ assert_eq!(version, CURRENT_PROTOCOL);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -435,9 +435,9 @@ fn reattach_after_detach_shows_current_state() {
// --- Client B (reattach) ---
let mut stream_b = UnixStream::connect(&client_socket).expect("client B should connect");
- let (version, error) =
- client_handshake(&mut stream_b, 14, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 14);
+ let (version, error) = client_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24)
+ .expect("handshake should succeed");
+ assert_eq!(version, CURRENT_PROTOCOL);
assert!(
error.is_none(),
"reattach handshake should succeed: {:?}",
@@ -516,8 +516,8 @@ fn processes_survive_during_and_after_detach() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect");
let (version, error) =
- client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 14);
+ client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, CURRENT_PROTOCOL);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -554,9 +554,9 @@ fn processes_survive_during_and_after_detach() {
// Reattach — verify we can connect and receive a frame.
let mut stream_b = UnixStream::connect(&client_socket).expect("should reattach");
- let (version, error) =
- client_handshake(&mut stream_b, 14, 80, 24).expect("reattach handshake should succeed");
- assert_eq!(version, 14);
+ let (version, error) = client_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24)
+ .expect("reattach handshake should succeed");
+ assert_eq!(version, CURRENT_PROTOCOL);
assert!(error.is_none(), "{:?}", error);
// Verify the reattached client receives a frame.
@@ -604,8 +604,8 @@ fn server_persists_after_client_connection_drop() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect");
let (version, error) =
- client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 14);
+ client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, CURRENT_PROTOCOL);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -630,9 +630,9 @@ fn server_persists_after_client_connection_drop() {
// Reattach — verify we can connect and handshake again.
let mut stream_b = UnixStream::connect(&client_socket).expect("should reattach");
- let (version, error) =
- client_handshake(&mut stream_b, 14, 80, 24).expect("reattach handshake should succeed");
- assert_eq!(version, 14);
+ let (version, error) = client_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24)
+ .expect("reattach handshake should succeed");
+ assert_eq!(version, CURRENT_PROTOCOL);
assert!(error.is_none(), "reattach should succeed: {:?}", error);
cleanup_spawned_herdr(spawned, base);
@@ -653,8 +653,8 @@ fn detached_output_preserves_last_attached_pty_size() {
let mut stream = UnixStream::connect(&client_socket).expect("client should connect");
let (version, error) =
- client_handshake(&mut stream, 14, 120, 40).expect("handshake should succeed");
- assert_eq!(version, 14);
+ client_handshake(&mut stream, CURRENT_PROTOCOL, 120, 40).expect("handshake should succeed");
+ assert_eq!(version, CURRENT_PROTOCOL);
assert!(error.is_none(), "{:?}", error);
drain_messages(&mut stream);
@@ -721,9 +721,9 @@ fn output_accumulated_while_detached_visible_on_reattach() {
// Connect and handshake client A.
let mut stream_a = UnixStream::connect(&client_socket).expect("client A should connect");
- let (version, error) =
- client_handshake(&mut stream_a, 14, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 14);
+ let (version, error) = client_handshake(&mut stream_a, CURRENT_PROTOCOL, 80, 24)
+ .expect("handshake should succeed");
+ assert_eq!(version, CURRENT_PROTOCOL);
assert!(error.is_none(), "{:?}", error);
// Detach client A immediately.
@@ -779,9 +779,9 @@ fn output_accumulated_while_detached_visible_on_reattach() {
// --- Client B (reattach) ---
let mut stream_b = UnixStream::connect(&client_socket).expect("client B should connect");
- let (version, error) =
- client_handshake(&mut stream_b, 14, 80, 24).expect("reattach handshake should succeed");
- assert_eq!(version, 14);
+ let (version, error) = client_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24)
+ .expect("reattach handshake should succeed");
+ assert_eq!(version, CURRENT_PROTOCOL);
assert!(error.is_none(), "{:?}", error);
// Client B should receive a frame with the current state.
diff --git a/tests/multi_client.rs b/tests/multi_client.rs
index 421a6756..d891a9f0 100644
--- a/tests/multi_client.rs
+++ b/tests/multi_client.rs
@@ -16,7 +16,7 @@ use serde::Deserialize;
use serde_json::Value;
use support::{
cleanup_test_base, register_runtime_dir, register_spawned_herdr_pid,
- unregister_spawned_herdr_pid,
+ unregister_spawned_herdr_pid, CURRENT_PROTOCOL,
};
fn unique_test_dir() -> PathBuf {
@@ -566,7 +566,7 @@ fn client_handshake(
fn connect_raw_client(client_socket: &Path, cols: u16, rows: u16) -> UnixStream {
let mut stream = UnixStream::connect(client_socket).expect("should connect to client socket");
- client_handshake(&mut stream, 14, cols, rows).expect("handshake should succeed");
+ client_handshake(&mut stream, CURRENT_PROTOCOL, cols, rows).expect("handshake should succeed");
stream
}
diff --git a/tests/server_headless.rs b/tests/server_headless.rs
index 6e5f03cd..5abb5ace 100644
--- a/tests/server_headless.rs
+++ b/tests/server_headless.rs
@@ -14,7 +14,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
use support::{
cleanup_test_base, register_runtime_dir, register_spawned_herdr_pid,
- unregister_spawned_herdr_pid,
+ unregister_spawned_herdr_pid, CURRENT_PROTOCOL,
};
fn unique_test_dir() -> PathBuf {
@@ -595,9 +595,12 @@ fn client_handshake_succeeds() {
// Send Hello with the current protocol version, 80 cols, 24 rows.
let (version, error) =
- client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed");
+ client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 14, "server should report protocol version 14");
+ assert_eq!(
+ version, CURRENT_PROTOCOL,
+ "server should report current protocol version"
+ );
assert!(
error.is_none(),
"handshake should not have an error: {:?}",
@@ -626,7 +629,10 @@ fn client_handshake_rejects_incompatible_version() {
let (version, error) = client_handshake(&mut stream, 0, 80, 24)
.expect("should read Welcome response even on rejection");
- assert_eq!(version, 14, "server should report its version 14");
+ assert_eq!(
+ version, CURRENT_PROTOCOL,
+ "server should report its current protocol version"
+ );
assert!(
error.is_some(),
"version 0 should be rejected with an error"
@@ -651,10 +657,10 @@ fn client_handshake_clamps_small_terminal_size() {
// Send Hello with 0x0 terminal size — should be clamped.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
- let (version, error) = client_handshake(&mut stream, 14, 0, 0)
+ let (version, error) = client_handshake(&mut stream, CURRENT_PROTOCOL, 0, 0)
.expect("handshake with 0x0 should succeed (server clamps)");
- assert_eq!(version, 14);
+ assert_eq!(version, CURRENT_PROTOCOL);
assert!(
error.is_none(),
"0x0 size should be accepted (clamped): {:?}",
@@ -714,9 +720,9 @@ fn no_hello_client_closed_within_five_seconds() {
// Verify the server is still healthy — a proper client can still connect.
let mut good_stream =
UnixStream::connect(&client_socket).expect("should connect after no-hello client");
- let (version, error) = client_handshake(&mut good_stream, 14, 80, 24)
+ let (version, error) = client_handshake(&mut good_stream, CURRENT_PROTOCOL, 80, 24)
.expect("proper handshake should still work after no-hello client");
- assert_eq!(version, 14);
+ assert_eq!(version, CURRENT_PROTOCOL);
assert!(error.is_none());
// API should still work.
diff --git a/tests/support/mod.rs b/tests/support/mod.rs
index 5359997f..5844bfa7 100644
--- a/tests/support/mod.rs
+++ b/tests/support/mod.rs
@@ -15,6 +15,7 @@ static INIT: Once = Once::new();
static CLEANUP_GUARD: OnceLock<CleanupGuard> = OnceLock::new();
const WATCHDOG_SCAN_INTERVAL: Duration = Duration::from_secs(1);
const RUNTIME_OWNER_MARKER: &str = ".herdr-test-owner-pid";
+pub const CURRENT_PROTOCOL: u32 = 15;
pub fn register_spawned_herdr_pid(pid: Option<u32>) {
let Some(pid) = pid else {