@team-agent/installer 0.3.23 → 0.3.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,689 @@
1
+ //! U1 波2 contracts (RED-first → GREEN after fix).
2
+ //!
3
+ //! Wave-2 closes three hardening gaps the wave-1 7-acute audit identified but
4
+ //! left untouched:
5
+ //!
6
+ //! * U1-A — `resolve_inject_target` LEADER branch had no liveness/fallback
7
+ //! gate: a stale `leader_receiver.pane_id` would be returned verbatim, and
8
+ //! the upstream `leader_receiver_pane_is_usable` would also fail (pane_id
9
+ //! no longer in `list_targets`, liveness Dead) → message goes terminal
10
+ //! `failed:leader_not_attached` on a mere pane-id drift. The wave-2 fix
11
+ //! widens "usable" to include "leader session+window exists" and lets the
12
+ //! resolver fall back to that live pane.
13
+ //! * U1-B — `transport.list_targets().unwrap_or_default()` SWALLOWED server
14
+ //! jitter (`Err` ≡ subprocess fork failed) as an empty vec. With the cache
15
+ //! codepath at :513-516 (`if live_targets.is_empty() && !known_dead { reuse }`)
16
+ //! a true jitter Err would reuse a never-validated cached_pane → injects
17
+ //! into a possibly-dead pane. The wave-2 fix DEFERs (degraded outcome,
18
+ //! `target_resolved` status) on Err and only treats Ok(empty) as authoritative
19
+ //! evidence that the session has no panes.
20
+ //! * U1-C — The delivery peek SITE at `delivery.rs:960` read `CaptureRange::Full`,
21
+ //! so the WHOLE scrollback (including answered-trust residue that scrolled off
22
+ //! screens ago) was classified — keeping a `Do you trust …` + `› 1.` glyph tail
23
+ //! alive forever and parking idle codex panes in `queued_until_trust`. Wave-2
24
+ //! narrows the peek to `Tail(80)`: a LIVE trust modal Codex pins to the visible
25
+ //! region (still answered), but residual scrolled-off modals no longer match.
26
+ //! **Recognizer recency** (the codex actionable-shape early-return at
27
+ //! `startup_prompt.rs:86`) is NOT changed in wave-2 — the rfind-position recency
28
+ //! model claude/copilot use is invalidated on Codex by CR-063 pre-render
29
+ //! (Update box + banner + `› Find…` input prompt pre-render BELOW a LIVE trust
30
+ //! modal — real-machine fixture `SUBROOT_FULL_TRUST_PANE_AFTER_QUICKSTART`
31
+ //! proves it). The narrower "scrollback residue idle-pane" problem needs a
32
+ //! Codex-specific shape (not rfind), tracked as U1-C-1 follow-up.
33
+
34
+ use super::*;
35
+ use crate::messaging::deliver_pending_message;
36
+ use crate::transport::test_support::OfflineTransport;
37
+ use crate::transport::{PaneLiveness, PaneId, SessionName, WindowName};
38
+
39
+ fn pane_info_w(pane_id: &str, session: &str, window: &str) -> PaneInfo {
40
+ PaneInfo {
41
+ pane_id: PaneId::new(pane_id),
42
+ session: SessionName::new(session),
43
+ window_index: None,
44
+ window_name: Some(WindowName::new(window)),
45
+ pane_index: None,
46
+ tty: None,
47
+ current_command: None,
48
+ current_path: None,
49
+ active: true,
50
+ pane_pid: None,
51
+ leader_env: BTreeMap::new(),
52
+ }
53
+ }
54
+
55
+ // ═════════════════════════════════════════════════════════════════════════════
56
+ // U1-A — LEADER pane_id drift fallback chain
57
+ // ═════════════════════════════════════════════════════════════════════════════
58
+
59
+ /// **Scenario**: state binds leader to `%old`, but tmux replaced it with `%new`
60
+ /// in the same `leader-session`/`leader` window (pane drift — leader is ALIVE,
61
+ /// just at a new id; this happens on tmux respawn-pane, `kill-pane` of an
62
+ /// inner shell, panel resize, etc.).
63
+ ///
64
+ /// **Pre-fix behaviour**: `leader_receiver_pane_is_usable` returns false (old
65
+ /// pane gone from list, liveness Dead) → mark failed leader_not_attached.
66
+ /// We never even reach `resolve_inject_target`.
67
+ ///
68
+ /// **Post-fix expectation**: usable widens to "leader session+window exists";
69
+ /// resolver falls back to the new live pane; delivery either delivers or fails
70
+ /// for an unrelated reason — but NOT `leader_not_attached`.
71
+ #[test]
72
+ #[ignore = "U1-A drift fallback excised for 0.3.24 (macmini RED v2, res_e4b40473d36f). \
73
+ Mock contract preserved verbatim for v0.3.25 fix-forward — the real-machine \
74
+ fix requires writer-shape + projection + rediscover-writer triad, NOT the \
75
+ wave-2 single-fn fallback. See .team/artifacts/u1-a-realmachine-v2-fix-or-excise.md."]
76
+ fn u1_a_leader_pane_drift_falls_back_to_live_session_window_not_failed() {
77
+ let ws = tmp_ws("u1a-drift");
78
+ let store = store_for(&ws);
79
+ let log = EventLog::new(&ws);
80
+ let state = serde_json::json!({
81
+ "session_name": "leader-session",
82
+ "leader_receiver": {
83
+ "pane_id": "%old",
84
+ "session_name": "leader-session",
85
+ "window_name": "leader",
86
+ },
87
+ });
88
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
89
+ let message_id = store
90
+ .create_message(None, "coordinator", "leader", "hi", None, false, None)
91
+ .unwrap();
92
+
93
+ // Drift: %old is GONE; %new took its place under the same session/window.
94
+ let transport = OfflineTransport::new()
95
+ .with_targets(vec![pane_info_w("%new", "leader-session", "leader")])
96
+ .with_session_present(true)
97
+ .with_liveness("%old", PaneLiveness::Dead)
98
+ .with_liveness("%new", PaneLiveness::Live);
99
+
100
+ let out = deliver_pending_message(&ws, &store, &transport, &message_id, &log, &state)
101
+ .unwrap();
102
+
103
+ assert!(
104
+ out.reason != Some(DeliveryRefusal::LeaderNotAttached),
105
+ "U1-A: pane DRIFT (not leader death) must not terminal-fail as \
106
+ leader_not_attached; got reason={:?} status={:?}",
107
+ out.reason,
108
+ out.message_status.0
109
+ );
110
+ assert_ne!(
111
+ out.message_status.0,
112
+ "failed",
113
+ "U1-A: drift must not be terminal; got status={}",
114
+ out.message_status.0
115
+ );
116
+ let inject_targets = transport.inject_targets();
117
+ assert!(
118
+ inject_targets.iter().any(|t| matches!(t, Target::Pane(p) if p.as_str() == "%new")),
119
+ "U1-A: fallback chain must inject into the LIVE pane (%new), not the stale \
120
+ %old; injected targets={inject_targets:?}"
121
+ );
122
+ }
123
+
124
+ /// **Scenario**: state has no `leader_receiver.pane_id` AND no leader session
125
+ /// metadata. Leader is genuinely not attached.
126
+ ///
127
+ /// **Expectation (unchanged by wave-2)**: still `leader_not_attached`. The
128
+ /// fallback chain MUST NOT swallow a real leader-not-attached into a synthetic
129
+ /// `SessionWindow{window:"leader"}` injection. Same invariant as `resolve_inject_target`
130
+ /// doc: "must not fall through to a synthetic SessionWindow{window=leader}".
131
+ #[test]
132
+ fn u1_a_leader_truly_not_attached_still_leader_not_attached_not_synthetic_target() {
133
+ let ws = tmp_ws("u1a-truly-dead");
134
+ let store = store_for(&ws);
135
+ let log = EventLog::new(&ws);
136
+ let state = serde_json::json!({
137
+ // no session_name, no leader_receiver pane/window — truly unattached
138
+ "leader_receiver": {},
139
+ });
140
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
141
+ let message_id = store
142
+ .create_message(None, "coordinator", "leader", "hi", None, false, None)
143
+ .unwrap();
144
+
145
+ // Transport returns NO panes at all (no leader window anywhere).
146
+ let transport = OfflineTransport::new()
147
+ .with_targets(vec![])
148
+ .with_session_present(false);
149
+
150
+ let out = deliver_pending_message(&ws, &store, &transport, &message_id, &log, &state)
151
+ .unwrap();
152
+
153
+ assert_eq!(
154
+ out.reason,
155
+ Some(DeliveryRefusal::LeaderNotAttached),
156
+ "U1-A: truly unattached leader must still terminal-fail as \
157
+ leader_not_attached (regression guard)"
158
+ );
159
+ assert!(
160
+ transport.inject_targets().is_empty(),
161
+ "U1-A: truly unattached leader must NOT attempt a synthetic inject; \
162
+ got {:?}",
163
+ transport.inject_targets()
164
+ );
165
+ }
166
+
167
+ // ═════════════════════════════════════════════════════════════════════════════
168
+ // U1-B — list_targets Err is server jitter, NOT empty
169
+ // ═════════════════════════════════════════════════════════════════════════════
170
+
171
+ /// **Scenario**: a WORKER recipient has `pane_id=%cached` (last-known) in state.
172
+ /// On this tick, tmux server jitters and `list_targets()` returns Err. The
173
+ /// cached pane has NOT been validated this tick.
174
+ ///
175
+ /// **Pre-fix behaviour**: `list_targets().unwrap_or_default()` → empty vec →
176
+ /// resolver falls through to `if live_targets.is_empty() && !cached_pane_known_dead`
177
+ /// → REUSES `%cached` blindly → inject into an unverified (possibly dead) pane.
178
+ ///
179
+ /// **Post-fix expectation**: Err is observed as such → delivery DEFERs (status
180
+ /// stays at `target_resolved`, NOT `delivered`, NOT `failed`), with a
181
+ /// jitter-named verification reason, so the next tick reattempts on a fresh
182
+ /// `list_targets`.
183
+ #[test]
184
+ fn u1_b_list_targets_err_defers_does_not_reuse_unvalidated_cached_pane() {
185
+ let ws = tmp_ws("u1b-jitter");
186
+ let store = store_for(&ws);
187
+ let log = EventLog::new(&ws);
188
+ let state = serde_json::json!({
189
+ "session_name": "team-jitter",
190
+ "agents": {
191
+ "w1": {
192
+ "provider": "fake",
193
+ "pane_id": "%cached",
194
+ "window": "w1",
195
+ }
196
+ }
197
+ });
198
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
199
+ let message_id = store
200
+ .create_message(None, "leader", "w1", "ping", None, false, None)
201
+ .unwrap();
202
+
203
+ let transport = OfflineTransport::new()
204
+ .with_list_targets_error("tmux server connection refused (jitter)")
205
+ .with_session_present(true)
206
+ .with_default_liveness(PaneLiveness::Live); // cached looks alive in isolation
207
+
208
+ let out = deliver_pending_message(&ws, &store, &transport, &message_id, &log, &state)
209
+ .unwrap();
210
+
211
+ assert!(
212
+ !out.ok,
213
+ "U1-B: jitter must not declare delivered (false-green guard)"
214
+ );
215
+ assert_ne!(
216
+ out.message_status.0,
217
+ "delivered",
218
+ "U1-B: jitter must not mark delivered; got status={}",
219
+ out.message_status.0
220
+ );
221
+ assert_ne!(
222
+ out.message_status.0,
223
+ "failed",
224
+ "U1-B: jitter must not terminal-fail this tick (defer); got status={}",
225
+ out.message_status.0
226
+ );
227
+ assert!(
228
+ transport.inject_targets().is_empty(),
229
+ "U1-B: jitter must NOT inject into the unvalidated cached_pane; \
230
+ injected={:?}",
231
+ transport.inject_targets()
232
+ );
233
+ let events = log.tail(0).unwrap();
234
+ assert!(
235
+ events.iter().any(|event| {
236
+ let reason = event.get("reason").and_then(serde_json::Value::as_str).unwrap_or("");
237
+ let verif = event.get("verification").and_then(serde_json::Value::as_str).unwrap_or("");
238
+ reason.contains("list_targets")
239
+ || verif.contains("list_targets")
240
+ || reason.contains("server_jitter")
241
+ || verif.contains("server_jitter")
242
+ }),
243
+ "U1-B: defer must carry a named reason (list_targets / server_jitter), \
244
+ not a silent skip; got events={events:?}"
245
+ );
246
+ }
247
+
248
+ /// **Scenario**: Ok(empty vec) — list_targets succeeded but the session is
249
+ /// genuinely empty (no panes at all). This is NOT jitter, it's authoritative
250
+ /// evidence the session has nothing.
251
+ ///
252
+ /// **Expectation (unchanged)**: existing fallthrough behaviour. The point is
253
+ /// that wave-2 must DISTINGUISH Err from Ok(empty); this guard test asserts the
254
+ /// Ok(empty) branch still flows to the pre-existing logic.
255
+ #[test]
256
+ fn u1_b_list_targets_ok_empty_is_authoritative_not_deferred() {
257
+ let ws = tmp_ws("u1b-emptyok");
258
+ let store = store_for(&ws);
259
+ let log = EventLog::new(&ws);
260
+ let state = serde_json::json!({
261
+ "session_name": "team-emptyok",
262
+ "agents": {
263
+ "w1": {
264
+ "provider": "fake",
265
+ "pane_id": "%cached",
266
+ "window": "w1",
267
+ }
268
+ }
269
+ });
270
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
271
+ let message_id = store
272
+ .create_message(None, "leader", "w1", "ping", None, false, None)
273
+ .unwrap();
274
+
275
+ // Ok(empty) + cached pane explicitly NOT known dead (has_pane=None, liveness=Unknown)
276
+ // → resolver should reuse cached_pane (pre-existing path). The point of this guard:
277
+ // wave-2 must NOT degrade the Ok(empty) branch into a defer.
278
+ let transport = OfflineTransport::new()
279
+ .with_targets(vec![])
280
+ .with_session_present(true)
281
+ .with_default_liveness(PaneLiveness::Unknown);
282
+
283
+ let out = deliver_pending_message(&ws, &store, &transport, &message_id, &log, &state)
284
+ .unwrap();
285
+
286
+ // We don't assert ok=true (the fake provider may or may not roundtrip);
287
+ // we assert the pre-existing inject ATTEMPT happened (i.e. NOT deferred
288
+ // silently), proving wave-2 did not over-tighten.
289
+ assert!(
290
+ !transport.inject_targets().is_empty(),
291
+ "U1-B regression guard: Ok(empty) is authoritative — resolver must \
292
+ still attempt the existing fallback (cached_pane reuse); got no \
293
+ injections (status={}, reason={:?})",
294
+ out.message_status.0,
295
+ out.reason
296
+ );
297
+ }
298
+
299
+ // ═════════════════════════════════════════════════════════════════════════════
300
+ // U1-C — delivery peek site narrows Full → Tail(80)
301
+ //
302
+ // NOTE on scope vs the wave-2 brief: the brief also called for an rfind-recency
303
+ // guard inside `has_actionable_trust_shape` mirroring claude/copilot. The
304
+ // real-machine fixture `SUBROOT_FULL_TRUST_PANE_AFTER_QUICKSTART` (CR-063 pre-
305
+ // render: Update box + banner + `› Find…` input prompt all rendered BELOW a
306
+ // LIVE trust modal in the SAME capture) proves rfind-position recency does NOT
307
+ // transfer to codex — banner-position > trust-position is a routine pre-render
308
+ // artefact, not a "trust is stale" signal. That sub-item is deferred to a
309
+ // follow-up (U1-C-1) that needs a codex-specific shape, not rfind. Wave-2
310
+ // keeps the narrower, defensible fix: delivery's pre-inject peek uses Tail(80)
311
+ // instead of Full, so scrolled-off answered-trust residue can no longer trick
312
+ // the gate into parking idle codex panes forever. A LIVE trust modal is pinned
313
+ // by codex to the visible region, so it still gets answered.
314
+ // ═════════════════════════════════════════════════════════════════════════════
315
+
316
+ /// **Pre-fix behaviour**: `delivery.rs:960` calls `capture(target, Full)` for
317
+ /// the startup-prompt peek site. Full reads the entire pane scrollback (up to
318
+ /// the configured limit), which on an idle codex worker can carry the
319
+ /// already-answered trust modal text long after dismissal.
320
+ ///
321
+ /// **Post-fix expectation**: the peek site uses `CaptureRange::Tail(80)`. The
322
+ /// peek is a delivery-time pre-check, NOT the startup-prompts dismissal phase
323
+ /// (that path needs Full to anchor recency on its own terms). We verify the
324
+ /// `CaptureRange` the gate passed to the transport.
325
+ #[test]
326
+ fn u1_c_delivery_peek_uses_tail_eighty_not_full() {
327
+ use crate::transport::CaptureRange;
328
+
329
+ let ws = tmp_ws("u1c-tail-range");
330
+ let store = store_for(&ws);
331
+ let log = EventLog::new(&ws);
332
+ let state = serde_json::json!({
333
+ "session_name": "team-tail-range",
334
+ "agents": {
335
+ "w1": {
336
+ "provider": "codex",
337
+ "pane_id": "%cdx",
338
+ "window": "w1",
339
+ "startup_prompts": "pending", // NOT handled — triggers peek path
340
+ }
341
+ }
342
+ });
343
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
344
+ let message_id = store
345
+ .create_message(None, "leader", "w1", "ping", None, false, None)
346
+ .unwrap();
347
+
348
+ // Empty capture text → classify returns KeepPolling → peek returns false → inject
349
+ // proceeds. We only care about WHICH range the peek site asked for.
350
+ let transport = OfflineTransport::new()
351
+ .with_targets(vec![pane_info_w("%cdx", "team-tail-range", "w1")])
352
+ .with_session_present(true)
353
+ .with_default_liveness(PaneLiveness::Live)
354
+ .with_capture_for_pane("%cdx", "OpenAI Codex\ncodex>\n");
355
+
356
+ let _ = deliver_pending_message(&ws, &store, &transport, &message_id, &log, &state)
357
+ .unwrap();
358
+
359
+ let ranges = transport.capture_ranges();
360
+ assert!(
361
+ !ranges.is_empty(),
362
+ "U1-C: delivery peek must call capture() for codex worker with non-terminal \
363
+ startup_prompts; got no capture calls"
364
+ );
365
+ assert!(
366
+ ranges.iter().any(|r| matches!(r, CaptureRange::Tail(n) if *n == 80)),
367
+ "U1-C: delivery peek site must request Tail(80), not Full. ranges={ranges:?}. \
368
+ Full was the pre-wave-2 default and caused scrolled-off answered-trust \
369
+ residue to be classified as actionable, parking idle codex panes forever."
370
+ );
371
+ assert!(
372
+ !ranges.iter().any(|r| matches!(r, CaptureRange::Full)),
373
+ "U1-C: delivery peek must NOT request Full (residue-trap). ranges={ranges:?}"
374
+ );
375
+ }
376
+
377
+ /// **Regression guard**: a TRULY actionable codex trust modal — startup_prompts
378
+ /// pending, Tail(80) capture shows the live trust modal in the visible region.
379
+ ///
380
+ /// **Expectation (unchanged)**: still parks as `queued_until_trust`. Wave-2 must
381
+ /// not regress the legitimate trust-defer path: a live modal in Tail(80) is
382
+ /// still detected by the unchanged `has_actionable_trust_shape` early-return.
383
+ #[test]
384
+ fn u1_c_delivery_real_trust_modal_still_parks_queued_until_trust() {
385
+ let ws = tmp_ws("u1c-realtrust");
386
+ let store = store_for(&ws);
387
+ let log = EventLog::new(&ws);
388
+ let state = serde_json::json!({
389
+ "session_name": "team-realtrust",
390
+ "agents": {
391
+ "w1": {
392
+ "provider": "codex",
393
+ "pane_id": "%cdx",
394
+ "window": "w1",
395
+ "startup_prompts": "pending",
396
+ }
397
+ }
398
+ });
399
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
400
+ let message_id = store
401
+ .create_message(None, "leader", "w1", "ping", None, false, None)
402
+ .unwrap();
403
+
404
+ // Live trust modal — fits well inside Tail(80).
405
+ let live_trust = "\
406
+ Loading codex...
407
+ Do you trust the contents of this directory?
408
+ › 1. Yes, continue
409
+ › 2. No, exit
410
+ ";
411
+
412
+ let transport = OfflineTransport::new()
413
+ .with_targets(vec![pane_info_w("%cdx", "team-realtrust", "w1")])
414
+ .with_session_present(true)
415
+ .with_default_liveness(PaneLiveness::Live)
416
+ .with_capture_for_pane("%cdx", live_trust);
417
+
418
+ let out = deliver_pending_message(&ws, &store, &transport, &message_id, &log, &state)
419
+ .unwrap();
420
+
421
+ assert_eq!(
422
+ out.message_status.0,
423
+ "queued_until_trust",
424
+ "U1-C regression guard: a live trust modal in Tail(80) must still park as \
425
+ queued_until_trust; got status={} reason={:?}",
426
+ out.message_status.0,
427
+ out.reason
428
+ );
429
+ }
430
+
431
+ // ═════════════════════════════════════════════════════════════════════════════
432
+ // U1-A wave-2 follow-on — REAL-MACHINE-FAITHFUL contracts for the projection-null
433
+ // regression that escaped the original wave-2 fix (macmini fixture
434
+ // `res_dc8f8c8e20f6`, see `.team/artifacts/u1-a-realmachine-fail-fix-spec.md`).
435
+ //
436
+ // The original U1-A test at line 72 of this file uses a SYNTHETIC state shape
437
+ // that pre-populates BOTH `state.session_name` AND `leader_receiver.session_name`
438
+ // AND omits `owner_team_id` on the message. All three are simultaneously WRONG
439
+ // for the real-machine isolated-team scenario, which is exactly why the wave-2
440
+ // fix shipped GREEN here but the bug reproduced on the device.
441
+ //
442
+ // The real-machine writer
443
+ // (`lifecycle/launch.rs::seed_launched_owner_from_caller_with_provider_lookup`,
444
+ // lines 4440-4468) builds the `leader_receiver` JSON WITHOUT `session_name`
445
+ // and `window_name`. Real-machine messages from worker_b → leader DO carry
446
+ // `owner_team_id` on the row at send time, so the delivery path at
447
+ // `messaging/delivery.rs:140-162` re-projects the runtime state through
448
+ // `project_state_for_owner_team` → `project_top_level_view`
449
+ // (`state/projection.rs:301-306`). The projection's `setdefault(null)` contract
450
+ // converts a missing `state.session_name` on the team entry to a JSON null at
451
+ // the projected top level. From there the fallback chain at `delivery.rs:604-606`
452
+ // (`leader_receiver_session_name`) hits null on both the receiver AND the
453
+ // projected root, returns None, the `!session.is_empty()` gate at
454
+ // `delivery.rs:542` is skipped, and the U1-A drift fallback in
455
+ // `leader_receiver_pane_is_usable` ALSO short-circuits with empty session —
456
+ // back to `LeaderNotAttached` on a live %new.
457
+ // ═════════════════════════════════════════════════════════════════════════════
458
+
459
+ /// **Real-machine scenario** (macmini fixture `res_dc8f8c8e20f6`):
460
+ /// * Isolated team `t1` launched via quick-start; tmux session = `team-iso`.
461
+ /// * `leader_receiver` written by the isolated-team seed path carries
462
+ /// `pane_id` / `mode` / `status` / `discovery` / `tmux_socket` but
463
+ /// **NO** `session_name` and **NO** `window_name` (writer never sets them
464
+ /// — see `lifecycle/launch.rs:4440-4468`).
465
+ /// * worker_b → leader message carries `owner_team_id = "t1"` on the SQLite
466
+ /// row (real-machine sender always populates this for isolated teams).
467
+ /// * On delivery tick, `tmux respawn-pane -k -t %old` had already drifted
468
+ /// the leader pane: `%old` is Dead, `%new` lives at session `team-iso`,
469
+ /// window `leader`.
470
+ ///
471
+ /// **Pre-fix behaviour (the bug that shipped in dd85ba7)**:
472
+ /// delivery.rs:140 projects state via `project_top_level_view` → projection
473
+ /// inserts `session_name: null` at projected top level
474
+ /// (`state/projection.rs:301-302` `unwrap_or(Value::Null)` branch).
475
+ /// Receiver lacks session_name → fallback at `delivery.rs:606` reads
476
+ /// projected `state.session_name` → null → `.as_str()` → None.
477
+ /// `leader_receiver_pane_is_usable` sees session=`""` → skips drift probe →
478
+ /// returns false → message terminal-fails `LeaderNotAttached`, even though
479
+ /// %new is alive at the right session+window.
480
+ ///
481
+ /// **Post-fix expectation**: projection.rs:300-306 falls back to the ROOT
482
+ /// `state.session_name` before inserting null. The drift probe then succeeds
483
+ /// and %new is selected as inject target.
484
+ ///
485
+ /// **0.3.24 status**: IGNORED. The wave-2 single-fn fallback (whose mock this
486
+ /// asserted) was excised after macmini RED v2 (res_e4b40473d36f) proved that
487
+ /// projection+drift-fallback alone do not close the real-machine gap. The
488
+ /// v0.3.25 fix-forward will re-enable this contract with a real-tmux macmini
489
+ /// counterpart — see `.team/artifacts/u1-a-realmachine-v2-fix-or-excise.md`.
490
+ #[test]
491
+ #[ignore = "U1-A deferred to v0.3.25 — wave-2 single-fn fallback excised for 0.3.24 ship"]
492
+ fn u1_a_projected_team_state_with_null_session_still_finds_drifted_leader_pane() {
493
+ let ws = tmp_ws("u1a-proj-null-session");
494
+ let store = store_for(&ws);
495
+ let log = EventLog::new(&ws);
496
+
497
+ // Real-machine state shape: nested `teams.t1` entry, receiver as the writer
498
+ // at lifecycle/launch.rs:4440-4468 produces it (NO session_name, NO window_name).
499
+ // Top-level `session_name` is the canonical isolated-team session; the team
500
+ // entry does NOT carry it (matches real-machine `state.json` snapshots).
501
+ let state = serde_json::json!({
502
+ "active_team_key": "t1",
503
+ "session_name": "team-iso",
504
+ "teams": {
505
+ "t1": {
506
+ // NOTE: no session_name on the team entry — this is exactly the
507
+ // projection-trigger condition. Real machine relies on the root
508
+ // session_name; the projection setdefault(null) destroys it.
509
+ "leader_receiver": {
510
+ "mode": "direct_tmux",
511
+ "status": "attached",
512
+ "pane_id": "%old",
513
+ "pane": "%old",
514
+ "leader_session_uuid": "00000000-0000-0000-0000-000000000001",
515
+ "owner_epoch": 1,
516
+ "discovery": "quick_start",
517
+ // NOTE: no tmux_socket (the isolated-team seed path at
518
+ // lifecycle/launch.rs:4440-4468 marks it OPTIONAL; setting a
519
+ // foreign socket here would route us through
520
+ // `delivery_transport_for_recipient`'s real-tmux endpoint
521
+ // backend, which is correct production behaviour but bypasses
522
+ // OfflineTransport and is asserted on by the real-machine
523
+ // gated stub at the end of this section).
524
+ // NOTE: no session_name. No window_name. Faithful to
525
+ // seed_launched_owner_from_caller_with_provider_lookup
526
+ // (lifecycle/launch.rs:4440-4468) which never writes them.
527
+ },
528
+ "team_owner": {
529
+ "pane_id": "%old",
530
+ "leader_session_uuid": "00000000-0000-0000-0000-000000000001",
531
+ "owner_epoch": 1,
532
+ "claimed_via": "quick-start",
533
+ },
534
+ "owner_epoch": 1,
535
+ "agents": {},
536
+ }
537
+ }
538
+ });
539
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
540
+
541
+ // Critical faithfulness knob: owner_team_id IS set on the row, exactly as
542
+ // the real-machine sender (worker_b → leader on isolated team) writes it.
543
+ // This is what triggers the projection path at delivery.rs:140-162 that
544
+ // the pre-existing u1_a test above silently skipped by passing None.
545
+ let message_id = store
546
+ .create_message(None, "worker_b", "leader", "hi", None, false, Some("t1"))
547
+ .unwrap();
548
+
549
+ // Drift: %old is Dead; %new took its place under session "team-iso", window "leader".
550
+ let transport = OfflineTransport::new()
551
+ .with_targets(vec![pane_info_w("%new", "team-iso", "leader")])
552
+ .with_session_present(true)
553
+ .with_liveness("%old", PaneLiveness::Dead)
554
+ .with_liveness("%new", PaneLiveness::Live);
555
+
556
+ let out = deliver_pending_message(&ws, &store, &transport, &message_id, &log, &state)
557
+ .unwrap();
558
+
559
+ assert!(
560
+ out.reason != Some(DeliveryRefusal::LeaderNotAttached),
561
+ "U1-A projection-null escape (real-machine fixture res_dc8f8c8e20f6): \
562
+ worker_b → leader on isolated team with owner_team_id projects state \
563
+ through project_top_level_view, which setdefault(null)s session_name \
564
+ when the team entry lacks it; the U1-A fallback chain then sees empty \
565
+ session and terminal-fails. Got reason={:?} status={:?} — must NOT be \
566
+ LeaderNotAttached when %new is live at session+window.",
567
+ out.reason,
568
+ out.message_status.0
569
+ );
570
+ assert_ne!(
571
+ out.message_status.0,
572
+ "failed",
573
+ "U1-A projection-null escape: drift via projected state must not be \
574
+ terminal; got status={}",
575
+ out.message_status.0
576
+ );
577
+ let inject_targets = transport.inject_targets();
578
+ assert!(
579
+ inject_targets.iter().any(|t| matches!(t, Target::Pane(p) if p.as_str() == "%new")),
580
+ "U1-A projection-null escape: fallback chain must inject into the LIVE \
581
+ pane (%new) reachable via session+window probe; injected={inject_targets:?}"
582
+ );
583
+ }
584
+
585
+ /// **Projection-primitive contract**: direct test on
586
+ /// `state::projection::project_top_level_view` that exposes the
587
+ /// `unwrap_or(Value::Null)` bug at `state/projection.rs:301-306` in isolation.
588
+ ///
589
+ /// **Pre-fix behaviour**: when the team entry lacks `session_name`, the
590
+ /// projection inserts JSON null at the projected top level, regardless of
591
+ /// whether the ROOT state has a valid `session_name`. Every downstream consumer
592
+ /// uses `.and_then(Value::as_str)` which treats null and missing identically,
593
+ /// so the bug is silent at projection time and only manifests at delivery time.
594
+ ///
595
+ /// **Post-fix expectation**: when the team entry lacks `session_name`, fall
596
+ /// back to the ROOT `state.session_name` before inserting null. This is the
597
+ /// minimal-blast-radius fix — when the team entry HAS session_name the
598
+ /// behaviour is bit-identical (entry branch wins), and when both lack it the
599
+ /// projected value is still null (unchanged → `project_top_level_view_golden`
600
+ /// + `project_top_level_view_does_not_fall_to_toplevel_owner_when_teams_exist`
601
+ /// in `state/projection.rs` stay green).
602
+ ///
603
+ /// **0.3.24 status**: IGNORED. The projection or_else fallback was reverted
604
+ /// along with the wave-2 U1-A drift fallback because it had no other consumer.
605
+ /// v0.3.25 will re-introduce it as part of the writer-shape + projection +
606
+ /// rediscover-writer triad.
607
+ #[test]
608
+ #[ignore = "U1-A deferred to v0.3.25 — projection or_else fallback reverted with the wave-2 excision"]
609
+ fn u1_a_project_top_level_view_falls_back_to_root_session_name_when_entry_lacks_it() {
610
+ let state = serde_json::json!({
611
+ "session_name": "root-sess",
612
+ "teams": {
613
+ "t1": {
614
+ "agents": {},
615
+ // NO session_name on the entry — projection must fall back to root.
616
+ }
617
+ }
618
+ });
619
+
620
+ let projected = crate::state::projection::project_top_level_view(&state, "t1");
621
+
622
+ let session = projected.get("session_name");
623
+ assert!(
624
+ session.is_some(),
625
+ "projection must produce a session_name key (matches Python setdefault \
626
+ contract); got {projected:?}"
627
+ );
628
+ assert_eq!(
629
+ session.and_then(serde_json::Value::as_str),
630
+ Some("root-sess"),
631
+ "U1-A projection contract: when team entry lacks session_name, the \
632
+ projection MUST fall back to ROOT state.session_name; instead it \
633
+ inserted {:?}. This is the root cause of the U1-A real-machine FAIL — \
634
+ every delivery.rs fallback that calls .and_then(Value::as_str) treats \
635
+ null as missing.",
636
+ session
637
+ );
638
+ }
639
+
640
+ /// **Regression guard**: when NEITHER the team entry NOR the root state has
641
+ /// `session_name`, projection must still produce a `session_name` key with
642
+ /// JSON null (Python `setdefault(null)` contract — keeps the existing
643
+ /// `project_top_level_view_does_not_fall_to_toplevel_owner_when_teams_exist`
644
+ /// behaviour intact at `state/projection.rs:706`).
645
+ #[test]
646
+ fn u1_a_project_top_level_view_inserts_null_session_name_when_neither_root_nor_entry_has_it() {
647
+ let state = serde_json::json!({
648
+ // NO root session_name.
649
+ "teams": {
650
+ "t1": {
651
+ "agents": {},
652
+ // NO entry session_name.
653
+ }
654
+ }
655
+ });
656
+ let projected = crate::state::projection::project_top_level_view(&state, "t1");
657
+ assert!(
658
+ projected.get("session_name").is_some(),
659
+ "regression guard: projection must always insert session_name key \
660
+ (Python setdefault contract); got {projected:?}"
661
+ );
662
+ assert_eq!(
663
+ projected.get("session_name"),
664
+ Some(&serde_json::Value::Null),
665
+ "regression guard: when neither root nor entry has session_name, the \
666
+ projected value is JSON null (unchanged from main HEAD behaviour); \
667
+ got {:?}",
668
+ projected.get("session_name")
669
+ );
670
+ }
671
+
672
+ // Real-machine integration stub (#[ignore]) — macmini scenario res_dc8f8c8e20f6.
673
+ // The deterministic source of truth is the three OfflineTransport contracts above.
674
+ // This stub claims the test name so the macmini harness can link it.
675
+ #[test]
676
+ #[ignore = "real-machine: macmini fixture res_dc8f8c8e20f6 — requires real tmux + isolated team launch + respawn-pane drift"]
677
+ fn u1_a_macmini_res_dc8f8c8e20f6_isolated_team_respawn_pane_drift_delivers() {
678
+ if std::env::var("TEAMAGENT_MACMINI_E2E").as_deref() != Ok("1") {
679
+ eprintln!("skipping: TEAMAGENT_MACMINI_E2E != 1");
680
+ return;
681
+ }
682
+ panic!(
683
+ "stub: implement against tools/macmini-ssh/run_macmini_gap32_u1a_drift_e2e.sh \
684
+ (follow-up). The OfflineTransport unit contracts above are the deterministic \
685
+ source of truth; a real-machine replay was observed manually on the macmini \
686
+ host (worker_b → leader, %old dead, %new alive, projection nulls \
687
+ session_name, terminal-fails leader_not_attached pre-fix)."
688
+ );
689
+ }