Skip to main content

agentmux_srv/reducer/
tab.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4use agentmux_common::ipc::{ErrorCode, Event};
5
6use crate::state::State;
7
8
9use crate::state::TabRecord;
10
11/// Phase E.2b — create a tab inside a workspace. Validates the
12/// parent exists; otherwise emits `Event::Error` (non-fatal). On
13/// success: assigns a UUID, appends to the workspace's `tab_ids`,
14/// inserts into `state.tabs`, emits `Event::TabCreated`. If the
15/// workspace had no active tab, the new tab also becomes active
16/// and an `Event::ActiveTabChanged` is emitted alongside.
17///
18/// NOT idempotent on retry (same UUID-assignment caveat as
19/// `handle_create_workspace`).
20pub(super) fn handle_create_tab(state: &mut State, workspace_id: String, name: String) -> Vec<Event> {
21    let Some(workspace_record) = state.workspaces.get(&workspace_id) else {
22        let v = state.bump_version();
23        return vec![Event::Error {
24            code: ErrorCode::InvalidCommand,
25            message: format!("CreateTab: workspace not found: {}", workspace_id),
26            fatal: false,
27            version: v,
28        }];
29    };
30    // codex P2 #622: auto-generate `Tab N` when name is empty,
31    // matching `wcore::create_tab`'s default-naming behaviour. The
32    // counter uses the reducer's tab_ids length + 1 (matching the
33    // old SQLite-side count: tabids.len() + pinnedtabids.len() + 1
34    // — pinnedtabids stays at zero in production since pinning
35    // was removed in E.2c.3b, so reducer-only counting matches).
36    let resolved_name = if name.is_empty() {
37        format!("Tab {}", workspace_record.tab_ids.len() + 1)
38    } else {
39        name
40    };
41    let tab_id = uuid::Uuid::new_v4().to_string();
42    state.tabs.insert(
43        tab_id.clone(),
44        TabRecord {
45            tab_id: tab_id.clone(),
46            workspace_id: workspace_id.clone(),
47            name: resolved_name.clone(),
48            block_ids: Vec::new(),
49            focused_node_id: String::new(),
50            magnified_node_id: String::new(),
51            rootnode: None,
52        },
53    );
54    let workspace = state.workspaces.get_mut(&workspace_id).expect("checked");
55    workspace.tab_ids.push(tab_id.clone());
56    let activated = if workspace.active_tab_id.is_none() {
57        workspace.active_tab_id = Some(tab_id.clone());
58        true
59    } else {
60        false
61    };
62    let mut events = Vec::with_capacity(2);
63    let v = state.bump_version();
64    events.push(Event::TabCreated {
65        workspace_id: workspace_id.clone(),
66        tab_id: tab_id.clone(),
67        name: resolved_name,
68        version: v,
69    });
70    if activated {
71        let v2 = state.bump_version();
72        events.push(Event::ActiveTabChanged {
73            workspace_id,
74            tab_id: Some(tab_id),
75            version: v2,
76        });
77    }
78    events
79}
80
81/// Phase E.2b — delete a tab from a workspace. Idempotent: deleting
82/// a missing tab is a silent no-op. If the deleted tab was the
83/// active tab, the workspace's active tab becomes the next tab in
84/// `tab_ids` (or the previous one if the deleted was last; or None
85/// if the workspace is now empty), and an `Event::ActiveTabChanged`
86/// is emitted alongside `Event::TabDeleted`.
87pub(super) fn handle_delete_tab(
88    state: &mut State,
89    workspace_id: String,
90    tab_id: String,
91    force: bool,
92) -> Vec<Event> {
93    let Some(workspace) = state.workspaces.get_mut(&workspace_id) else {
94        return Vec::new();
95    };
96    let Some(pos) = workspace.tab_ids.iter().position(|t| t == &tab_id) else {
97        return Vec::new();
98    };
99    // Last-tab guard with `force` bypass (round 4 of PR #633).
100    // History:
101    //   * Round 1: saga pre-check → reagent flagged TOCTOU race.
102    //   * Round 2: moved guard to reducer (atomic) → broke CreateTab
103    //     compensation (codex P1 round 2) and Cmd+W keyboard flow
104    //     (codex P1 round 1).
105    //   * Round 3: removed guard entirely; saga keeps soft pre-check
106    //     → codex P2 round 4 re-flagged the TOCTOU race.
107    //   * Round 4 (this): atomic guard with `force: bool` bypass.
108    //     User-facing flows (CloseTab RPC → DeleteTab saga) pass
109    //     `force: false`; compensation paths (`CreateTab` rollback,
110    //     `PromoteBlockToTab.ctx.compensate`) pass `force: true`.
111    //     Frontend keyboard handler `simpleCloseStaticTab` already
112    //     gates pre-RPC, so the reducer rejection is a defense-in-
113    //     depth backstop that catches automation/race paths.
114    if !force && workspace.tab_ids.len() <= 1 {
115        let v = state.bump_version();
116        return vec![Event::Error {
117            code: ErrorCode::InvalidCommand,
118            message: format!(
119                "DeleteTab: refusing to delete the last tab in workspace {} (would leave empty workspace; pass force=true for compensation paths)",
120                workspace_id,
121            ),
122            fatal: false,
123            version: v,
124        }];
125    }
126    workspace.tab_ids.remove(pos);
127    let active_changed = if workspace.active_tab_id.as_deref() == Some(tab_id.as_str()) {
128        let new_active = workspace
129            .tab_ids
130            .get(pos)
131            .or_else(|| pos.checked_sub(1).and_then(|i| workspace.tab_ids.get(i)))
132            .cloned();
133        workspace.active_tab_id = new_active.clone();
134        Some(new_active)
135    } else {
136        None
137    };
138    let removed_tab = state.tabs.remove(&tab_id);
139    // Phase E.3 — cascade to blocks. Subscribers observing TabDeleted
140    // are expected to drop dependent block state (no per-block
141    // BlockDeleted events emitted; mirrors workspace→tabs cascade
142    // semantics). `block_ids` on the event below carries the cascaded ids
143    // anyway (issue #2218, B.4) — the host uses it to tear down any
144    // browser-pane renderer that was never live/loaded in a window and so
145    // never got a chance to reach the renderer-mediated close path.
146    let cascaded_block_ids: Vec<String> = removed_tab
147        .as_ref()
148        .map(|t| t.block_ids.clone())
149        .unwrap_or_default();
150    if let Some(tab) = &removed_tab {
151        for block_id in &tab.block_ids {
152            state.blocks.remove(block_id);
153        }
154    }
155    let mut events = Vec::with_capacity(2);
156    let v = state.bump_version();
157    events.push(Event::TabDeleted {
158        workspace_id: workspace_id.clone(),
159        tab_id,
160        block_ids: cascaded_block_ids,
161        version: v,
162    });
163    if let Some(new_active) = active_changed {
164        let v2 = state.bump_version();
165        events.push(Event::ActiveTabChanged {
166            workspace_id,
167            tab_id: new_active,
168            version: v2,
169        });
170    }
171    events
172}
173
174/// Phase E.2b — set a workspace's active tab. No-op if already
175/// active. Errors (non-fatal) if the workspace doesn't exist or the
176/// tab isn't in that workspace's tab list.
177pub(super) fn handle_set_active_tab(
178    state: &mut State,
179    workspace_id: String,
180    tab_id: String,
181) -> Vec<Event> {
182    let Some(workspace) = state.workspaces.get_mut(&workspace_id) else {
183        let v = state.bump_version();
184        return vec![Event::Error {
185            code: ErrorCode::InvalidCommand,
186            message: format!("SetActiveTab: workspace not found: {}", workspace_id),
187            fatal: false,
188            version: v,
189        }];
190    };
191    if !workspace.tab_ids.iter().any(|t| t == &tab_id) {
192        let v = state.bump_version();
193        return vec![Event::Error {
194            code: ErrorCode::InvalidCommand,
195            message: format!(
196                "SetActiveTab: tab {} not in workspace {}",
197                tab_id, workspace_id
198            ),
199            fatal: false,
200            version: v,
201        }];
202    }
203    if workspace.active_tab_id.as_deref() == Some(tab_id.as_str()) {
204        return Vec::new();
205    }
206    workspace.active_tab_id = Some(tab_id.clone());
207    let v = state.bump_version();
208    vec![Event::ActiveTabChanged {
209        workspace_id,
210        tab_id: Some(tab_id),
211        version: v,
212    }]
213}
214
215/// Phase E.2c.3b — reorder a tab within its workspace's
216/// `tab_ids`. `new_index` is clamped to `tab_ids.len()`. No-op
217/// if the tab is already at that position. Errors (non-fatal) if
218/// the workspace doesn't exist or the tab isn't in its tab list.
219pub(super) fn handle_reorder_tab(
220    state: &mut State,
221    workspace_id: String,
222    tab_id: String,
223    new_index: u32,
224) -> Vec<Event> {
225    let Some(workspace) = state.workspaces.get_mut(&workspace_id) else {
226        let v = state.bump_version();
227        return vec![Event::Error {
228            code: ErrorCode::InvalidCommand,
229            message: format!("ReorderTab: workspace not found: {}", workspace_id),
230            fatal: false,
231            version: v,
232        }];
233    };
234    let Some(current_pos) = workspace.tab_ids.iter().position(|t| t == &tab_id) else {
235        let v = state.bump_version();
236        return vec![Event::Error {
237            code: ErrorCode::InvalidCommand,
238            message: format!(
239                "ReorderTab: tab {} not in workspace {}",
240                tab_id, workspace_id
241            ),
242            fatal: false,
243            version: v,
244        }];
245    };
246    let len = workspace.tab_ids.len();
247    let target = (new_index as usize).min(len.saturating_sub(1));
248    if current_pos == target {
249        return Vec::new();
250    }
251    let id = workspace.tab_ids.remove(current_pos);
252    workspace.tab_ids.insert(target, id);
253    let v = state.bump_version();
254    vec![Event::TabReordered {
255        workspace_id,
256        tab_id,
257        new_index: target as u32,
258        version: v,
259    }]
260}
261
262/// Phase E.5.3 — replace a workspace's `tab_ids` with the given
263/// list. Validates the workspace exists and the new list is a
264/// permutation of the current set (same elements, possibly
265/// different order). No-op if identical.
266pub(super) fn handle_reorder_tabs_bulk(
267    state: &mut State,
268    workspace_id: String,
269    tab_ids: Vec<String>,
270) -> Vec<Event> {
271    // SPEC_864 Phase 5 — strict validation reinstated. The codex P1 #620
272    // membership relaxation existed because `MoveTabToWorkspace` and
273    // `PromoteBlockToTab` used to write through wcore without dispatching
274    // reducer commands, leaving `workspace.tab_ids` stale here. Both are
275    // now reducer-routed (`MoveTabToWorkspace` dispatches `MoveTab`
276    // directly; `PromoteBlockToTab`, `TearOffBlock`, and `TearOffTab` run
277    // through sagas whose steps dispatch `CreateTab`/`MoveTab`/`MoveBlock`)
278    // — every path that changes a workspace's tab set now updates the
279    // reducer in the same step, so `tab_ids` can't legitimately drift
280    // from SQLite anymore. `tab_ids` must be a permutation of the
281    // reducer's own set, not a caller-asserted replacement.
282    let Some(current) = state.workspaces.get(&workspace_id).map(|w| w.tab_ids.clone()) else {
283        let v = state.bump_version();
284        return vec![Event::Error {
285            code: ErrorCode::InvalidCommand,
286            message: format!("ReorderTabsBulk: workspace not found: {}", workspace_id),
287            fatal: false,
288            version: v,
289        }];
290    };
291    {
292        let mut seen: std::collections::HashSet<&String> =
293            std::collections::HashSet::with_capacity(tab_ids.len());
294        for id in &tab_ids {
295            if !seen.insert(id) {
296                let v = state.bump_version();
297                return vec![Event::Error {
298                    code: ErrorCode::InvalidCommand,
299                    message: format!(
300                        "ReorderTabsBulk: tab_ids contains duplicate entry: {}",
301                        id
302                    ),
303                    fatal: false,
304                    version: v,
305                }];
306            }
307        }
308    }
309    if current.len() != tab_ids.len()
310        || !current.iter().all(|id| tab_ids.contains(id))
311    {
312        let v = state.bump_version();
313        return vec![Event::Error {
314            code: ErrorCode::InvalidCommand,
315            message: format!(
316                "ReorderTabsBulk: tab_ids must be a permutation of the workspace's current tabs (got {:?}, expected a permutation of {:?})",
317                tab_ids, current
318            ),
319            fatal: false,
320            version: v,
321        }];
322    }
323    if current == tab_ids {
324        return Vec::new();
325    }
326    state.workspaces.get_mut(&workspace_id).expect("checked above").tab_ids = tab_ids.clone();
327    let v = state.bump_version();
328    vec![Event::TabsReorderedBulk {
329        workspace_id,
330        tab_ids,
331        version: v,
332    }]
333}
334
335/// Phase E.5.5 — move a tab from `src_workspace_id` to
336/// `dst_workspace_id`, inserting at `dst_index` (clamped to dst's
337/// length). Updates the tab's `workspace_id`, removes it from src's
338/// `tab_ids`, inserts into dst's `tab_ids`. If the tab was src's
339/// `active_tab_id`, src's active reverts to its first remaining
340/// tab (or `None` when empty).
341///
342/// Errors when:
343/// * source / dest workspace not found,
344/// * tab not found,
345/// * `tab.workspace_id != src_workspace_id` (caller-side bug),
346/// * `src_workspace_id == dst_workspace_id` (use `ReorderTab` for
347///   intra-workspace reorders — same-workspace moves through this
348///   path would create ambiguity around `dst_index` semantics).
349pub(super) fn handle_move_tab(
350    state: &mut State,
351    tab_id: String,
352    src_workspace_id: String,
353    dst_workspace_id: String,
354    dst_index: u32,
355) -> Vec<Event> {
356    if src_workspace_id == dst_workspace_id {
357        let v = state.bump_version();
358        return vec![Event::Error {
359            code: ErrorCode::InvalidCommand,
360            message: "MoveTab: src and dst workspaces are identical; use ReorderTab".into(),
361            fatal: false,
362            version: v,
363        }];
364    }
365    // Strict validation (Phase E.4 strict-mode flip): the
366    // migration-tolerant lazy-import fallback (codex P1 round-2
367    // #621) was removed once the soak window closed with no
368    // `lazy-import` warnings observed in production. All reducer-
369    // routed paths now keep `state.tabs` and
370    // `state.workspaces[*].tab_ids` consistent with SQLite, so we
371    // can reject unknown tabs and workspace_id mismatches outright.
372    if !state.workspaces.contains_key(&src_workspace_id) {
373        let v = state.bump_version();
374        return vec![Event::Error {
375            code: ErrorCode::InvalidCommand,
376            message: format!("MoveTab: src workspace not found: {}", src_workspace_id),
377            fatal: false,
378            version: v,
379        }];
380    }
381    if !state.workspaces.contains_key(&dst_workspace_id) {
382        let v = state.bump_version();
383        return vec![Event::Error {
384            code: ErrorCode::InvalidCommand,
385            message: format!("MoveTab: dst workspace not found: {}", dst_workspace_id),
386            fatal: false,
387            version: v,
388        }];
389    }
390    let tab_workspace_id: Option<String> =
391        state.tabs.get(&tab_id).map(|t| t.workspace_id.clone());
392    match tab_workspace_id {
393        None => {
394            let v = state.bump_version();
395            return vec![Event::Error {
396                code: ErrorCode::InvalidCommand,
397                message: format!("MoveTab: tab not found in state: {}", tab_id),
398                fatal: false,
399                version: v,
400            }];
401        }
402        Some(actual_ws) if actual_ws != src_workspace_id => {
403            let v = state.bump_version();
404            return vec![Event::Error {
405                code: ErrorCode::InvalidCommand,
406                message: format!(
407                    "MoveTab: workspace_id mismatch — tab {} belongs to {}, not {}",
408                    tab_id, actual_ws, src_workspace_id
409                ),
410                fatal: false,
411                version: v,
412            }];
413        }
414        Some(_) => {}
415    }
416
417    // Remove from src.
418    let new_src_active_tab_id: Option<String> = {
419        let src = state.workspaces.get_mut(&src_workspace_id).expect("checked");
420        src.tab_ids.retain(|id| id != &tab_id);
421        if src.active_tab_id.as_deref() == Some(tab_id.as_str()) {
422            src.active_tab_id = src.tab_ids.first().cloned();
423        }
424        src.active_tab_id.clone()
425    };
426
427    // Insert into dst at clamped index. Set the moved tab as dst's
428    // new active tab — mirrors wcore::move_tab_to_workspace
429    // behaviour and addresses codex P2 #621 (dst.active_tab_id was
430    // previously left untouched, so a saga-driven tear-off could
431    // produce a destination workspace with no active tab selected).
432    let final_dst_index: u32 = {
433        let dst = state.workspaces.get_mut(&dst_workspace_id).expect("checked");
434        let clamped = (dst_index as usize).min(dst.tab_ids.len());
435        dst.tab_ids.insert(clamped, tab_id.clone());
436        dst.active_tab_id = Some(tab_id.clone());
437        clamped as u32
438    };
439
440    // Update the tab's parent.
441    state
442        .tabs
443        .get_mut(&tab_id)
444        .expect("checked")
445        .workspace_id = dst_workspace_id.clone();
446
447    let v = state.bump_version();
448    vec![Event::TabMoved {
449        tab_id: tab_id.clone(),
450        src_workspace_id,
451        dst_workspace_id,
452        dst_index: final_dst_index,
453        new_src_active_tab_id,
454        new_dst_active_tab_id: Some(tab_id),
455        version: v,
456    }]
457}
458
459/// Phase E.5.3 — rename a tab. Errors if missing; no-op if the
460/// name is unchanged.
461pub(super) fn handle_rename_tab(state: &mut State, tab_id: String, name: String) -> Vec<Event> {
462    let Some(tab) = state.tabs.get_mut(&tab_id) else {
463        let v = state.bump_version();
464        return vec![Event::Error {
465            code: ErrorCode::InvalidCommand,
466            message: format!("RenameTab: tab not found: {}", tab_id),
467            fatal: false,
468            version: v,
469        }];
470    };
471    if tab.name == name {
472        return Vec::new();
473    }
474    tab.name = name.clone();
475    let v = state.bump_version();
476    vec![Event::TabRenamed {
477        tab_id,
478        name,
479        version: v,
480    }]
481}
482
483/// Phase E.5.3 — pass-through for tab meta updates. Same shape as
484/// `handle_update_workspace_meta`.
485pub(super) fn handle_update_tab_meta(
486    state: &mut State,
487    tab_id: String,
488    meta_patch: serde_json::Value,
489) -> Vec<Event> {
490    if !state.tabs.contains_key(&tab_id) {
491        let v = state.bump_version();
492        return vec![Event::Error {
493            code: ErrorCode::InvalidCommand,
494            message: format!("UpdateTabMeta: tab not found: {}", tab_id),
495            fatal: false,
496            version: v,
497        }];
498    }
499    let v = state.bump_version();
500    vec![Event::TabMetaUpdated {
501        tab_id,
502        meta_patch,
503        version: v,
504    }]
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510    use crate::reducer::test_support::*;
511    use crate::reducer::update;
512    use agentmux_common::ipc::Command;
513
514    #[test]
515    fn create_tab_validates_workspace_exists() {
516        let mut state = State::default();
517        let events = update(
518            &mut state,
519            Command::CreateTab {
520                workspace_id: "no-such-ws".into(),
521                name: "t1".into(),
522            },
523            &ctx(1),
524        );
525        assert!(matches!(&events[0], Event::Error { .. }));
526        assert!(state.tabs.is_empty());
527    }
528
529    #[test]
530    fn create_tab_first_tab_becomes_active() {
531        let mut state = State::default();
532        let ws_id = create_workspace(&mut state, "w");
533        let events = update(
534            &mut state,
535            Command::CreateTab {
536                workspace_id: ws_id.clone(),
537                name: "t1".into(),
538            },
539            &ctx(1),
540        );
541        // First event: TabCreated; second: ActiveTabChanged.
542        assert!(matches!(&events[0], Event::TabCreated { .. }));
543        assert!(matches!(&events[1], Event::ActiveTabChanged { .. }));
544        let workspace = &state.workspaces[&ws_id];
545        assert_eq!(workspace.tab_ids.len(), 1);
546        assert_eq!(workspace.active_tab_id, Some(workspace.tab_ids[0].clone()));
547        assert_eq!(state.tabs.len(), 1);
548    }
549
550    #[test]
551    fn create_tab_second_tab_does_not_steal_active() {
552        let mut state = State::default();
553        let ws_id = create_workspace(&mut state, "w");
554        let _ = update(
555            &mut state,
556            Command::CreateTab {
557                workspace_id: ws_id.clone(),
558                name: "t1".into(),
559            },
560            &ctx(1),
561        );
562        let first_active = state.workspaces[&ws_id].active_tab_id.clone();
563        let events = update(
564            &mut state,
565            Command::CreateTab {
566                workspace_id: ws_id.clone(),
567                name: "t2".into(),
568            },
569            &ctx(2),
570        );
571        // Only TabCreated — second tab does not become active.
572        assert!(matches!(&events[0], Event::TabCreated { .. }));
573        assert_eq!(events.len(), 1);
574        assert_eq!(state.workspaces[&ws_id].active_tab_id, first_active);
575        assert_eq!(state.workspaces[&ws_id].tab_ids.len(), 2);
576    }
577
578    #[test]
579    fn delete_tab_removes_from_state_and_workspace_list() {
580        let mut state = State::default();
581        let ws_id = create_workspace(&mut state, "w");
582        let _ = update(
583            &mut state,
584            Command::CreateTab {
585                workspace_id: ws_id.clone(),
586                name: "t1".into(),
587            },
588            &ctx(1),
589        );
590        let _ = update(
591            &mut state,
592            Command::CreateTab {
593                workspace_id: ws_id.clone(),
594                name: "t2".into(),
595            },
596            &ctx(2),
597        );
598        let tab2_id = state.workspaces[&ws_id].tab_ids[1].clone();
599        let events = update(
600            &mut state,
601            Command::DeleteTab {
602                workspace_id: ws_id.clone(),
603                tab_id: tab2_id.clone(),
604                force: false,
605            },
606            &ctx(3),
607        );
608        assert!(matches!(&events[0], Event::TabDeleted { .. }));
609        // tab2 wasn't active, so no ActiveTabChanged.
610        assert_eq!(events.len(), 1);
611        assert!(!state.tabs.contains_key(&tab2_id));
612        assert_eq!(state.workspaces[&ws_id].tab_ids.len(), 1);
613    }
614
615    #[test]
616    fn delete_active_tab_promotes_neighbor() {
617        let mut state = State::default();
618        let ws_id = create_workspace(&mut state, "w");
619        let _ = update(
620            &mut state,
621            Command::CreateTab {
622                workspace_id: ws_id.clone(),
623                name: "t1".into(),
624            },
625            &ctx(1),
626        );
627        let _ = update(
628            &mut state,
629            Command::CreateTab {
630                workspace_id: ws_id.clone(),
631                name: "t2".into(),
632            },
633            &ctx(2),
634        );
635        let tab1_id = state.workspaces[&ws_id].tab_ids[0].clone();
636        let tab2_id = state.workspaces[&ws_id].tab_ids[1].clone();
637        // tab1 was created first → it's active. Delete it.
638        let events = update(
639            &mut state,
640            Command::DeleteTab {
641                workspace_id: ws_id.clone(),
642                tab_id: tab1_id.clone(),
643                force: false,
644            },
645            &ctx(3),
646        );
647        assert!(matches!(&events[0], Event::TabDeleted { .. }));
648        assert!(matches!(
649            &events[1],
650            Event::ActiveTabChanged { tab_id: Some(_), .. }
651        ));
652        // tab2 should now be active.
653        assert_eq!(state.workspaces[&ws_id].active_tab_id, Some(tab2_id));
654    }
655
656    #[test]
657    fn delete_last_tab_clears_active_to_none() {
658        // Reducer accepts last-tab delete (round 2 of PR #633 walked
659        // back the guard). User-facing flows gate at the call site
660        // (close button + keymodel both check `tab_ids.len() <= 1`);
661        // internal compensation paths rely on this acceptance to
662        // roll back failed CreateTab persists.
663        let mut state = State::default();
664        let ws_id = create_workspace(&mut state, "w");
665        let _ = update(
666            &mut state,
667            Command::CreateTab {
668                workspace_id: ws_id.clone(),
669                name: "t1".into(),
670            },
671            &ctx(1),
672        );
673        let tab1_id = state.workspaces[&ws_id].tab_ids[0].clone();
674        let events = update(
675            &mut state,
676            Command::DeleteTab {
677                workspace_id: ws_id.clone(),
678                tab_id: tab1_id,
679                // Last-tab delete needs force=true post-round-4
680                // (codex P2 #633). Test asserts the
681                // ActiveTabChanged-to-None behavior still works
682                // when compensation paths force a last-tab delete.
683                force: true,
684            },
685            &ctx(2),
686        );
687        assert!(matches!(&events[0], Event::TabDeleted { .. }));
688        assert!(matches!(
689            &events[1],
690            Event::ActiveTabChanged { tab_id: None, .. }
691        ));
692        assert_eq!(state.workspaces[&ws_id].active_tab_id, None);
693        assert!(state.tabs.is_empty());
694    }
695
696    #[test]
697    fn delete_unknown_tab_silent_no_op() {
698        let mut state = State::default();
699        let ws_id = create_workspace(&mut state, "w");
700        let events = update(
701            &mut state,
702            Command::DeleteTab {
703                workspace_id: ws_id,
704                tab_id: "ghost".into(),
705                force: false,
706            },
707            &ctx(1),
708        );
709        assert!(events.is_empty());
710    }
711
712    #[test]
713    fn set_active_tab_validates_workspace_and_tab() {
714        let mut state = State::default();
715        let ws_id = create_workspace(&mut state, "w");
716        let _ = update(
717            &mut state,
718            Command::CreateTab {
719                workspace_id: ws_id.clone(),
720                name: "t1".into(),
721            },
722            &ctx(1),
723        );
724        // Wrong workspace.
725        let events = update(
726            &mut state,
727            Command::SetActiveTab {
728                workspace_id: "no-such".into(),
729                tab_id: "x".into(),
730            },
731            &ctx(2),
732        );
733        assert!(matches!(&events[0], Event::Error { .. }));
734        // Right workspace, wrong tab.
735        let events = update(
736            &mut state,
737            Command::SetActiveTab {
738                workspace_id: ws_id,
739                tab_id: "ghost".into(),
740            },
741            &ctx(3),
742        );
743        assert!(matches!(&events[0], Event::Error { .. }));
744    }
745
746    #[test]
747    fn set_active_tab_idempotent_no_event_when_already_active() {
748        let mut state = State::default();
749        let ws_id = create_workspace(&mut state, "w");
750        let _ = update(
751            &mut state,
752            Command::CreateTab {
753                workspace_id: ws_id.clone(),
754                name: "t1".into(),
755            },
756            &ctx(1),
757        );
758        let tab_id = state.workspaces[&ws_id].tab_ids[0].clone();
759        // Already active (auto-activated on first tab create).
760        let events = update(
761            &mut state,
762            Command::SetActiveTab {
763                workspace_id: ws_id,
764                tab_id,
765            },
766            &ctx(2),
767        );
768        assert!(events.is_empty());
769    }
770
771    #[test]
772    fn reorder_tab_moves_to_new_index() {
773        let mut state = State::default();
774        let ws_id = create_workspace(&mut state, "w");
775        for i in 1..=3 {
776            let _ = update(
777                &mut state,
778                Command::CreateTab {
779                    workspace_id: ws_id.clone(),
780                    name: format!("t{}", i),
781                },
782                &ctx(i),
783            );
784        }
785        let original = state.workspaces[&ws_id].tab_ids.clone();
786        // Move first tab to index 2 (last).
787        let events = update(
788            &mut state,
789            Command::ReorderTab {
790                workspace_id: ws_id.clone(),
791                tab_id: original[0].clone(),
792                new_index: 2,
793            },
794            &ctx(10),
795        );
796        assert!(matches!(&events[0], Event::TabReordered { .. }));
797        let after = &state.workspaces[&ws_id].tab_ids;
798        assert_eq!(after[0], original[1]);
799        assert_eq!(after[1], original[2]);
800        assert_eq!(after[2], original[0]);
801    }
802
803    #[test]
804    fn reorder_tab_clamps_to_last_index() {
805        let mut state = State::default();
806        let ws_id = create_workspace(&mut state, "w");
807        let _ = update(
808            &mut state,
809            Command::CreateTab {
810                workspace_id: ws_id.clone(),
811                name: "t1".into(),
812            },
813            &ctx(1),
814        );
815        let _ = update(
816            &mut state,
817            Command::CreateTab {
818                workspace_id: ws_id.clone(),
819                name: "t2".into(),
820            },
821            &ctx(2),
822        );
823        let original = state.workspaces[&ws_id].tab_ids.clone();
824        // Asking for index 99 should clamp to 1 (len-1).
825        let events = update(
826            &mut state,
827            Command::ReorderTab {
828                workspace_id: ws_id.clone(),
829                tab_id: original[0].clone(),
830                new_index: 99,
831            },
832            &ctx(3),
833        );
834        if let Event::TabReordered { new_index, .. } = &events[0] {
835            assert_eq!(*new_index, 1);
836        } else {
837            panic!("expected TabReordered");
838        }
839    }
840
841    #[test]
842    fn reorder_tab_already_at_position_no_op() {
843        let mut state = State::default();
844        let ws_id = create_workspace(&mut state, "w");
845        let _ = update(
846            &mut state,
847            Command::CreateTab {
848                workspace_id: ws_id.clone(),
849                name: "t1".into(),
850            },
851            &ctx(1),
852        );
853        let tab_id = state.workspaces[&ws_id].tab_ids[0].clone();
854        let events = update(
855            &mut state,
856            Command::ReorderTab {
857                workspace_id: ws_id,
858                tab_id,
859                new_index: 0,
860            },
861            &ctx(2),
862        );
863        assert!(events.is_empty());
864    }
865
866    #[test]
867    fn reorder_tab_validates_workspace_and_tab() {
868        let mut state = State::default();
869        let events = update(
870            &mut state,
871            Command::ReorderTab {
872                workspace_id: "no-such-ws".into(),
873                tab_id: "x".into(),
874                new_index: 0,
875            },
876            &ctx(1),
877        );
878        assert!(matches!(&events[0], Event::Error { .. }));
879        let ws_id = create_workspace(&mut state, "w");
880        let events = update(
881            &mut state,
882            Command::ReorderTab {
883                workspace_id: ws_id,
884                tab_id: "ghost".into(),
885                new_index: 0,
886            },
887            &ctx(2),
888        );
889        assert!(matches!(&events[0], Event::Error { .. }));
890    }
891
892    /// SPEC_864 Phase 5 — strict validation reinstated: `MoveTabToWorkspace`,
893    /// `PromoteBlockToTab`, `TearOffBlock`, and `TearOffTab` are all
894    /// reducer-routed now, so `workspace.tab_ids` can't legitimately
895    /// diverge from the caller's view. A `tab_ids` list containing an id
896    /// the reducer doesn't know about for this workspace must be rejected,
897    /// not silently accepted as the codex P1 #620 migration-window
898    /// relaxation used to do.
899    #[test]
900    fn reorder_tabs_bulk_rejects_unknown_ids() {
901        let mut state = State::default();
902        let ws_id = create_workspace(&mut state, "w");
903        let known = create_tab(&mut state, &ws_id, "known");
904        let unknown = "unknown-tab".to_string();
905        let events = update(
906            &mut state,
907            Command::ReorderTabsBulk {
908                workspace_id: ws_id.clone(),
909                tab_ids: vec![unknown.clone(), known.clone()],
910            },
911            &ctx(99),
912        );
913        match &events[0] {
914            Event::Error { code, message, .. } => {
915                assert_eq!(*code, ErrorCode::InvalidCommand);
916                assert!(
917                    message.contains("permutation"),
918                    "error should mention permutation, got: {}",
919                    message
920                );
921            }
922            other => panic!("expected Error event, got {:?}", other),
923        }
924        // Rejected — workspace's tab_ids must be untouched.
925        let ws = state.workspaces.get(&ws_id).expect("ws still present");
926        assert_eq!(ws.tab_ids, vec![known]);
927    }
928
929    /// The permutation itself (same set, new order) must still succeed —
930    /// that's the whole point of the command.
931    #[test]
932    fn reorder_tabs_bulk_accepts_permutation_of_known_ids() {
933        let mut state = State::default();
934        let ws_id = create_workspace(&mut state, "w");
935        let t1 = create_tab(&mut state, &ws_id, "t1");
936        let t2 = create_tab(&mut state, &ws_id, "t2");
937        let events = update(
938            &mut state,
939            Command::ReorderTabsBulk {
940                workspace_id: ws_id.clone(),
941                tab_ids: vec![t2.clone(), t1.clone()],
942            },
943            &ctx(99),
944        );
945        assert!(
946            matches!(&events[0], Event::TabsReorderedBulk { .. }),
947            "expected TabsReorderedBulk, got {:?}",
948            events.first()
949        );
950        let ws = state.workspaces.get(&ws_id).expect("ws still present");
951        assert_eq!(ws.tab_ids, vec![t2, t1]);
952    }
953
954    /// A `tab_ids` list missing a tab the reducer knows about (short
955    /// permutation) must also be rejected, not silently drop the tab.
956    #[test]
957    fn reorder_tabs_bulk_rejects_missing_known_id() {
958        let mut state = State::default();
959        let ws_id = create_workspace(&mut state, "w");
960        let t1 = create_tab(&mut state, &ws_id, "t1");
961        let _t2 = create_tab(&mut state, &ws_id, "t2");
962        let events = update(
963            &mut state,
964            Command::ReorderTabsBulk {
965                workspace_id: ws_id,
966                tab_ids: vec![t1],
967            },
968            &ctx(99),
969        );
970        assert!(matches!(&events[0], Event::Error { .. }));
971    }
972
973    /// codex P1 #620 carryover: a duplicate tab_id in the new list
974    /// is still rejected — that would corrupt the persisted ordering
975    /// in a way the subscriber can't recover from.
976    #[test]
977    fn reorder_tabs_bulk_rejects_duplicates() {
978        let mut state = State::default();
979        let ws_id = create_workspace(&mut state, "w");
980        let t1 = create_tab(&mut state, &ws_id, "t1");
981        let events = update(
982            &mut state,
983            Command::ReorderTabsBulk {
984                workspace_id: ws_id,
985                tab_ids: vec![t1.clone(), t1.clone()],
986            },
987            &ctx(2),
988        );
989        match &events[0] {
990            Event::Error { code, message, .. } => {
991                assert_eq!(*code, ErrorCode::InvalidCommand);
992                assert!(
993                    message.contains("duplicate"),
994                    "error should mention duplicate, got: {}",
995                    message
996                );
997            }
998            other => panic!("expected Error event, got {:?}", other),
999        }
1000    }
1001
1002    #[test]
1003    fn reorder_tabs_bulk_validates_workspace() {
1004        let mut state = State::default();
1005        let events = update(
1006            &mut state,
1007            Command::ReorderTabsBulk {
1008                workspace_id: "no-such-ws".into(),
1009                tab_ids: vec!["a".into()],
1010            },
1011            &ctx(1),
1012        );
1013        assert!(matches!(&events[0], Event::Error { .. }));
1014    }
1015
1016    /// issue #2218 (B.4): TabDeleted/WorkspaceDeleted never emitted a
1017    /// per-block BlockDeleted, so the host had no signal to tear down a
1018    /// browser-pane renderer whose tab/workspace was deleted while it was
1019    /// never live/loaded in a window. `block_ids` on these events is that
1020    /// signal — this test locks the cascade actually carries them.
1021    #[test]
1022    fn delete_tab_emits_cascaded_block_ids() {
1023        let mut state = State::default();
1024        let ws_id = create_workspace(&mut state, "w");
1025        let _ = update(
1026            &mut state,
1027            Command::CreateTab { workspace_id: ws_id.clone(), name: "t1".into() },
1028            &ctx(1),
1029        );
1030        let tab1_id = state.workspaces[&ws_id].tab_ids[0].clone();
1031        // A second tab so the delete isn't a last-tab delete (needs force).
1032        let _ = update(
1033            &mut state,
1034            Command::CreateTab { workspace_id: ws_id.clone(), name: "t2".into() },
1035            &ctx(2),
1036        );
1037        let block1 = match &update(
1038            &mut state,
1039            Command::CreateBlock { tab_id: tab1_id.clone(), meta: serde_json::Value::Null },
1040            &ctx(3),
1041        )[0] {
1042            Event::BlockCreated { block_id, .. } => block_id.clone(),
1043            other => panic!("expected BlockCreated, got {:?}", other),
1044        };
1045        let block2 = match &update(
1046            &mut state,
1047            Command::CreateBlock { tab_id: tab1_id.clone(), meta: serde_json::Value::Null },
1048            &ctx(4),
1049        )[0] {
1050            Event::BlockCreated { block_id, .. } => block_id.clone(),
1051            other => panic!("expected BlockCreated, got {:?}", other),
1052        };
1053        let events = update(
1054            &mut state,
1055            Command::DeleteTab { workspace_id: ws_id, tab_id: tab1_id, force: false },
1056            &ctx(5),
1057        );
1058        match &events[0] {
1059            Event::TabDeleted { block_ids, .. } => {
1060                assert_eq!(block_ids.len(), 2);
1061                assert!(block_ids.contains(&block1));
1062                assert!(block_ids.contains(&block2));
1063            }
1064            other => panic!("expected TabDeleted, got {:?}", other),
1065        }
1066    }
1067
1068    #[test]
1069    fn delete_tab_with_zero_blocks_emits_empty_block_ids() {
1070        let mut state = State::default();
1071        let ws_id = create_workspace(&mut state, "w");
1072        let _ = update(
1073            &mut state,
1074            Command::CreateTab { workspace_id: ws_id.clone(), name: "t1".into() },
1075            &ctx(1),
1076        );
1077        let tab1_id = state.workspaces[&ws_id].tab_ids[0].clone();
1078        let _ = update(
1079            &mut state,
1080            Command::CreateTab { workspace_id: ws_id.clone(), name: "t2".into() },
1081            &ctx(2),
1082        );
1083        let events = update(
1084            &mut state,
1085            Command::DeleteTab { workspace_id: ws_id, tab_id: tab1_id, force: false },
1086            &ctx(3),
1087        );
1088        match &events[0] {
1089            Event::TabDeleted { block_ids, .. } => assert!(block_ids.is_empty()),
1090            other => panic!("expected TabDeleted, got {:?}", other),
1091        }
1092    }
1093
1094    /// codex P2 #622: empty name auto-generates `Tab N`, mirroring
1095    /// `wcore::create_tab`'s default-naming behaviour. Without this,
1096    /// CreateWindow's "fresh workspace" path + TearOffBlock's new tab
1097    /// would land with blank titles — a user-visible regression.
1098    #[test]
1099    fn create_tab_auto_generates_tab_n_when_name_empty() {
1100        let mut state = State::default();
1101        let ws_id = create_workspace(&mut state, "w");
1102        let events = update(
1103            &mut state,
1104            Command::CreateTab {
1105                workspace_id: ws_id.clone(),
1106                name: String::new(),
1107            },
1108            &ctx(2),
1109        );
1110        match &events[0] {
1111            Event::TabCreated { name, tab_id, .. } => {
1112                assert_eq!(name, "Tab 1", "first tab in fresh workspace");
1113                assert_eq!(state.tabs[tab_id].name, "Tab 1");
1114            }
1115            other => panic!("expected TabCreated, got {:?}", other),
1116        }
1117        // Second empty-name CreateTab → "Tab 2".
1118        let events = update(
1119            &mut state,
1120            Command::CreateTab {
1121                workspace_id: ws_id.clone(),
1122                name: String::new(),
1123            },
1124            &ctx(3),
1125        );
1126        match &events[0] {
1127            Event::TabCreated { name, .. } => assert_eq!(name, "Tab 2"),
1128            other => panic!("expected TabCreated, got {:?}", other),
1129        }
1130        // Explicit non-empty name passes through verbatim.
1131        let events = update(
1132            &mut state,
1133            Command::CreateTab {
1134                workspace_id: ws_id,
1135                name: "my custom tab".into(),
1136            },
1137            &ctx(4),
1138        );
1139        match &events[0] {
1140            Event::TabCreated { name, .. } => assert_eq!(name, "my custom tab"),
1141            other => panic!("expected TabCreated, got {:?}", other),
1142        }
1143    }
1144
1145    #[test]
1146    fn delete_tab_cascades_blocks() {
1147        let mut state = State::default();
1148        let ws_id = create_workspace(&mut state, "w");
1149        let tab_id = create_tab(&mut state, &ws_id, "t");
1150        let _ = update(
1151            &mut state,
1152            Command::CreateBlock { tab_id: tab_id.clone(), meta: serde_json::Value::Null },
1153            &ctx(2),
1154        );
1155        let _ = update(
1156            &mut state,
1157            Command::CreateBlock { tab_id: tab_id.clone(), meta: serde_json::Value::Null },
1158            &ctx(3),
1159        );
1160        assert_eq!(state.blocks.len(), 2);
1161        let _ = update(
1162            &mut state,
1163            Command::DeleteTab {
1164                workspace_id: ws_id,
1165                tab_id,
1166                // Single-tab workspace; force=true bypasses last-tab
1167                // guard so we can test the block cascade.
1168                force: true,
1169            },
1170            &ctx(4),
1171        );
1172        assert!(state.blocks.is_empty());
1173    }
1174
1175    // ---- Phase E.5.5 — MoveTab tests ----
1176
1177    #[test]
1178    fn move_tab_cross_workspace_updates_lists_and_parent() {
1179        let mut state = State::default();
1180        let src = create_workspace(&mut state, "src");
1181        let dst = create_workspace(&mut state, "dst");
1182        let t1 = create_tab(&mut state, &src, "t1");
1183        let t2 = create_tab(&mut state, &src, "t2");
1184        let dst_existing = create_tab(&mut state, &dst, "existing");
1185        let events = update(
1186            &mut state,
1187            Command::MoveTab {
1188                tab_id: t1.clone(),
1189                src_workspace_id: src.clone(),
1190                dst_workspace_id: dst.clone(),
1191                dst_index: 0,
1192            },
1193            &ctx(99),
1194        );
1195        match &events[0] {
1196            Event::TabMoved {
1197                tab_id,
1198                src_workspace_id,
1199                dst_workspace_id,
1200                dst_index,
1201                new_src_active_tab_id,
1202                ..
1203            } => {
1204                assert_eq!(tab_id, &t1);
1205                assert_eq!(src_workspace_id, &src);
1206                assert_eq!(dst_workspace_id, &dst);
1207                assert_eq!(*dst_index, 0);
1208                assert_eq!(new_src_active_tab_id, &Some(t2.clone()));
1209            }
1210            other => panic!("expected TabMoved, got {:?}", other),
1211        }
1212        assert_eq!(state.workspaces[&src].tab_ids, vec![t2.clone()]);
1213        assert_eq!(state.workspaces[&dst].tab_ids, vec![t1.clone(), dst_existing]);
1214        assert_eq!(state.tabs[&t1].workspace_id, dst);
1215        assert_eq!(state.workspaces[&src].active_tab_id, Some(t2));
1216    }
1217
1218    #[test]
1219    fn move_tab_clamps_dst_index_to_dst_length() {
1220        let mut state = State::default();
1221        let src = create_workspace(&mut state, "src");
1222        let dst = create_workspace(&mut state, "dst");
1223        let t1 = create_tab(&mut state, &src, "t1");
1224        let _ = create_tab(&mut state, &src, "filler");
1225        let events = update(
1226            &mut state,
1227            Command::MoveTab {
1228                tab_id: t1.clone(),
1229                src_workspace_id: src,
1230                dst_workspace_id: dst.clone(),
1231                dst_index: 999,
1232            },
1233            &ctx(2),
1234        );
1235        match &events[0] {
1236            Event::TabMoved { dst_index, .. } => assert_eq!(*dst_index, 0),
1237            other => panic!("expected TabMoved, got {:?}", other),
1238        }
1239        assert_eq!(state.workspaces[&dst].tab_ids, vec![t1]);
1240    }
1241
1242    #[test]
1243    fn move_tab_src_active_clears_when_workspace_empties() {
1244        let mut state = State::default();
1245        let src = create_workspace(&mut state, "src");
1246        let dst = create_workspace(&mut state, "dst");
1247        let only_tab = create_tab(&mut state, &src, "only");
1248        let events = update(
1249            &mut state,
1250            Command::MoveTab {
1251                tab_id: only_tab,
1252                src_workspace_id: src.clone(),
1253                dst_workspace_id: dst,
1254                dst_index: 0,
1255            },
1256            &ctx(2),
1257        );
1258        match &events[0] {
1259            Event::TabMoved {
1260                new_src_active_tab_id,
1261                ..
1262            } => assert_eq!(new_src_active_tab_id, &None),
1263            other => panic!("expected TabMoved, got {:?}", other),
1264        }
1265        assert_eq!(state.workspaces[&src].active_tab_id, None);
1266        assert!(state.workspaces[&src].tab_ids.is_empty());
1267    }
1268
1269    #[test]
1270    fn move_tab_rejects_same_workspace() {
1271        let mut state = State::default();
1272        let ws = create_workspace(&mut state, "w");
1273        let t1 = create_tab(&mut state, &ws, "t1");
1274        let events = update(
1275            &mut state,
1276            Command::MoveTab {
1277                tab_id: t1,
1278                src_workspace_id: ws.clone(),
1279                dst_workspace_id: ws,
1280                dst_index: 0,
1281            },
1282            &ctx(2),
1283        );
1284        assert!(matches!(&events[0], Event::Error { .. }));
1285    }
1286
1287    #[test]
1288    fn move_tab_rejects_unknown_src_or_dst_or_tab() {
1289        let mut state = State::default();
1290        let src = create_workspace(&mut state, "src");
1291        let dst = create_workspace(&mut state, "dst");
1292        let t1 = create_tab(&mut state, &src, "t1");
1293
1294        let events = update(
1295            &mut state,
1296            Command::MoveTab {
1297                tab_id: t1.clone(),
1298                src_workspace_id: "no-such-src".into(),
1299                dst_workspace_id: dst.clone(),
1300                dst_index: 0,
1301            },
1302            &ctx(2),
1303        );
1304        assert!(matches!(&events[0], Event::Error { .. }));
1305
1306        let events = update(
1307            &mut state,
1308            Command::MoveTab {
1309                tab_id: t1.clone(),
1310                src_workspace_id: src.clone(),
1311                dst_workspace_id: "no-such-dst".into(),
1312                dst_index: 0,
1313            },
1314            &ctx(3),
1315        );
1316        assert!(matches!(&events[0], Event::Error { .. }));
1317
1318        // Phase E.4 strict-mode flip: unknown tabs are now REJECTED.
1319        // The migration-tolerant lazy-import fallback was removed once
1320        // the soak window closed without `lazy-import` warnings being
1321        // observed in production. See `move_tab_unknown_tab_rejects`
1322        // for the dedicated test.
1323        let events = update(
1324            &mut state,
1325            Command::MoveTab {
1326                tab_id: "ghost-tab".into(),
1327                src_workspace_id: src,
1328                dst_workspace_id: dst,
1329                dst_index: 0,
1330            },
1331            &ctx(4),
1332        );
1333        assert!(matches!(&events[0], Event::Error { .. }));
1334    }
1335
1336    /// Phase E.4 strict-mode flip: an unknown tab id (not present in
1337    /// `state.tabs`) is rejected with a clear "tab not found" error
1338    /// rather than being lazy-imported. Replaces the migration-window
1339    /// `move_tab_lazy_imports_unknown_tab` test.
1340    #[test]
1341    fn move_tab_unknown_tab_rejects() {
1342        let mut state = State::default();
1343        let src = create_workspace(&mut state, "src");
1344        let dst = create_workspace(&mut state, "dst");
1345        let unknown_id = "unknown-tab-xyz".to_string();
1346        assert!(!state.tabs.contains_key(&unknown_id));
1347        let events = update(
1348            &mut state,
1349            Command::MoveTab {
1350                tab_id: unknown_id.clone(),
1351                src_workspace_id: src,
1352                dst_workspace_id: dst,
1353                dst_index: 0,
1354            },
1355            &ctx(2),
1356        );
1357        match &events[0] {
1358            Event::Error { code, message, .. } => {
1359                assert_eq!(*code, ErrorCode::InvalidCommand);
1360                assert!(
1361                    message.contains("tab not found"),
1362                    "error should mention `tab not found`, got: {}",
1363                    message
1364                );
1365            }
1366            other => panic!("expected Error event, got {:?}", other),
1367        }
1368        // No lazy import side-effect.
1369        assert!(!state.tabs.contains_key(&unknown_id));
1370    }
1371
1372    /// Phase E.4 strict-mode flip: a known tab whose reducer-state
1373    /// `workspace_id` doesn't match `src_workspace_id` is rejected.
1374    /// Replaces the migration-window
1375    /// `move_tab_tolerates_workspace_id_mismatch_during_migration`
1376    /// test.
1377    #[test]
1378    fn move_tab_wrong_workspace_rejects() {
1379        let mut state = State::default();
1380        let real_src = create_workspace(&mut state, "real_src");
1381        let dst = create_workspace(&mut state, "dst");
1382        let other = create_workspace(&mut state, "other");
1383        let t1 = create_tab(&mut state, &real_src, "t1");
1384        let filler = create_tab(&mut state, &other, "filler");
1385        // Claim the tab lives in `other` even though it actually
1386        // belongs to `real_src` per reducer state.
1387        let events = update(
1388            &mut state,
1389            Command::MoveTab {
1390                tab_id: t1.clone(),
1391                src_workspace_id: other.clone(),
1392                dst_workspace_id: dst.clone(),
1393                dst_index: 0,
1394            },
1395            &ctx(2),
1396        );
1397        match &events[0] {
1398            Event::Error { code, message, .. } => {
1399                assert_eq!(*code, ErrorCode::InvalidCommand);
1400                assert!(
1401                    message.contains("workspace_id mismatch"),
1402                    "error should mention `workspace_id mismatch`, got: {}",
1403                    message
1404                );
1405            }
1406            other => panic!("expected Error event, got {:?}", other),
1407        }
1408        // Reducer state untouched: t1 still in real_src, filler still
1409        // in other, dst empty.
1410        assert_eq!(state.tabs[&t1].workspace_id, real_src);
1411        assert_eq!(state.workspaces[&real_src].tab_ids, vec![t1]);
1412        assert_eq!(state.workspaces[&other].tab_ids, vec![filler]);
1413        assert!(state.workspaces[&dst].tab_ids.is_empty());
1414    }
1415}