@team-agent/installer 0.5.78 → 0.5.80

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.
package/Cargo.lock CHANGED
@@ -581,7 +581,7 @@ dependencies = [
581
581
 
582
582
  [[package]]
583
583
  name = "team-agent"
584
- version = "0.5.78"
584
+ version = "0.5.80"
585
585
  dependencies = [
586
586
  "anyhow",
587
587
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent", "crates/win-conpty-phase0", "crates/conpty-trans
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.5.78"
12
+ version = "0.5.80"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -248,6 +248,37 @@ mod tests {
248
248
  None
249
249
  );
250
250
  }
251
+
252
+ #[cfg(unix)]
253
+ #[test]
254
+ fn search_survives_unrelated_spaced_model_ids() {
255
+ let catalog = include_str!("../lifecycle/tests/fixtures/pi_list_models_spaced_ids.stdout.txt");
256
+ let path = fixture(&format!("printf '%s' '{catalog}'"));
257
+ let result = cmd_models_with(
258
+ &ModelsArgs {
259
+ provider: "pi".into(),
260
+ search: Some("gpt-5.6-luna".into()),
261
+ json: true,
262
+ },
263
+ &path,
264
+ Duration::from_secs(10),
265
+ MAX_CATALOG_BYTES,
266
+ )
267
+ .unwrap();
268
+ cleanup_fixture(&path);
269
+ assert_eq!(result.exit, ExitCode::Ok);
270
+ let CmdOutput::Human(text) = result.output else {
271
+ panic!("expected JSON projection")
272
+ };
273
+ let value: Value = serde_json::from_str(&text).unwrap();
274
+ assert_eq!(value["ok"], true);
275
+ assert_eq!(value["auth"], "ok");
276
+ let models = value["models"].as_array().unwrap();
277
+ assert_eq!(models.len(), 2);
278
+ assert_eq!(models[0]["role_model"], "openai-codex/gpt-5.6-luna");
279
+ assert_eq!(models[1]["role_model"], "team-agent/gpt-5.6-luna");
280
+ }
281
+
251
282
  #[cfg(unix)]
252
283
  fn fixture(body: &str) -> std::path::PathBuf {
253
284
  use std::os::unix::fs::PermissionsExt;
@@ -58,7 +58,9 @@ pub(crate) fn compute_runtime_freshness(
58
58
  for (agent_id, entry) in watch {
59
59
  let exited =
60
60
  entry.get("worker_provider_exited").and_then(Value::as_bool) == Some(true);
61
- if exited {
61
+ if exited
62
+ && crate::state::abnormal_watch::matches_observation(state, agent_id, entry)
63
+ {
62
64
  provider_exited_agents.insert(agent_id.clone());
63
65
  }
64
66
  }
@@ -28,7 +28,12 @@ fn health_fixture() -> crate::coordinator::HealthReport {
28
28
 
29
29
  #[test]
30
30
  fn runtime_freshness_provider_exit_requires_typed_positive_fact() {
31
- let state = serde_json::json!({
31
+ let mut state = serde_json::json!({
32
+ "active_team_key": "status-team",
33
+ "agents": {
34
+ "pane_only": {"provider": "codex", "rollout_path": "/fixture/pane", "spawn_epoch": 1},
35
+ "typed_exit": {"provider": "codex", "rollout_path": "/fixture/typed", "spawn_epoch": 1}
36
+ },
32
37
  "coordinator": {
33
38
  "abnormal_exit_watch": {
34
39
  "pane_only": {
@@ -43,6 +48,10 @@ fn runtime_freshness_provider_exit_requires_typed_positive_fact() {
43
48
  }
44
49
  });
45
50
 
51
+ for id in ["pane_only", "typed_exit"] {
52
+ state["coordinator"]["abnormal_exit_watch"][id]["watch_identity"] =
53
+ crate::state::abnormal_watch::observation_identity(&state, id).unwrap();
54
+ }
46
55
  let freshness = compute_runtime_freshness(
47
56
  Path::new("/nonexistent/status-port-typed-provider-exit-test"),
48
57
  &state,
@@ -52,3 +61,30 @@ fn runtime_freshness_provider_exit_requires_typed_positive_fact() {
52
61
  assert!(!freshness.provider_exited_agents.contains("pane_only"));
53
62
  assert!(freshness.provider_exited_agents.contains("typed_exit"));
54
63
  }
64
+
65
+ #[test]
66
+ fn runtime_freshness_rejects_foreign_legacy_and_unknown_watch_identity() {
67
+ let mut state = json!({
68
+ "active_team_key": "a",
69
+ "agents": {"w": {"provider": "codex", "rollout_path": "/fixture/w", "spawn_epoch": 1}},
70
+ "coordinator": {"abnormal_exit_watch": {"w": {"worker_provider_exited": true}}}
71
+ });
72
+ let path = Path::new("/nonexistent/status-watch-188");
73
+ assert!(compute_runtime_freshness(path, &state, &health_fixture())
74
+ .provider_exited_agents
75
+ .is_empty());
76
+ let identity = crate::state::abnormal_watch::observation_identity(&state, "w").unwrap();
77
+ state["coordinator"]["abnormal_exit_watch"]["w"]["watch_identity"] = identity;
78
+ for (pointer, value) in [
79
+ ("/active_team_key", json!("b")),
80
+ ("/agents/w/provider", json!("claude")),
81
+ ("/agents/w/spawn_epoch", json!(2)),
82
+ ("/agents/w/rollout_path", json!(null)),
83
+ ] {
84
+ let mut changed = state.clone();
85
+ *changed.pointer_mut(pointer).unwrap() = value;
86
+ assert!(compute_runtime_freshness(path, &changed, &health_fixture())
87
+ .provider_exited_agents
88
+ .is_empty());
89
+ }
90
+ }
@@ -73,6 +73,8 @@ pub(crate) fn detect_abnormal_exits(
73
73
  let team = crate::state::projection::team_state_key(&snapshot);
74
74
  let session_name = snapshot.get("session_name").and_then(Value::as_str);
75
75
  for agent in abnormal_watch_agents(&snapshot) {
76
+ // Reject untrusted history without skipping a fresh provider/path observation.
77
+ crate::state::abnormal_watch::begin_observation(state, &agent.agent_id);
76
78
  // Pane/process liveness is independent of transcript content. A
77
79
  // frozen rollout is common after pane death, so probe before the
78
80
  // metadata dedupe gate; only the expensive tail scan stays deduped.
@@ -109,7 +111,7 @@ pub(crate) fn detect_abnormal_exits(
109
111
  // re-read below.
110
112
  if let (Some(mtime), Some(stored)) = (
111
113
  mtime_ns,
112
- abnormal_watch_stored_metadata(&snapshot, &agent.agent_id),
114
+ abnormal_watch_stored_metadata(state, &agent.agent_id),
113
115
  ) {
114
116
  if stored == (size, mtime) {
115
117
  refresh_abnormal_watch_liveness(state, &agent.agent_id, &liveness);
@@ -149,7 +151,7 @@ pub(crate) fn detect_abnormal_exits(
149
151
  .map(|fact| abnormal_error_observation_key(&agent, fact));
150
152
  let error_observation_cohort = fact.as_ref().map(|_| abnormal_error_cohort_key(&agent));
151
153
  let error_recency = abnormal_error_recency(
152
- &snapshot,
154
+ state,
153
155
  &agent,
154
156
  error_observation_key.as_deref(),
155
157
  error_observation_cohort.as_deref(),
@@ -954,6 +956,9 @@ fn abnormal_watch_payload(
954
956
  }
955
957
 
956
958
  fn upsert_abnormal_watch(state: &mut Value, agent_id: &str, mut payload: Value) {
959
+ if let Some(identity) = crate::state::abnormal_watch::observation_identity(state, agent_id) {
960
+ payload["watch_identity"] = identity;
961
+ }
957
962
  let preserved = [
958
963
  "last_notified_key",
959
964
  "last_notified_at",
@@ -1413,6 +1418,121 @@ mod tests {
1413
1418
  crate::provider::get_adapter(provider)
1414
1419
  }
1415
1420
  }
1421
+ #[test]
1422
+ fn resource188_foreign_metadata_and_dedupe_cannot_skip_new_baseline() {
1423
+ let dir = temp_abnormal_dir("188-foreign-watch");
1424
+ let rollout = dir.join("rollout-w1.jsonl");
1425
+ let error = "{\"method\":\"turn/completed\",\"params\":{\"turn\":{\"id\":\"old\",\"status\":\"failed\"}}}\n";
1426
+ std::fs::write(&rollout, error).unwrap();
1427
+ seed_abnormal_state(&dir, &rollout, "alive", 1);
1428
+ let mut state = crate::state::persist::load_runtime_state(&dir).unwrap();
1429
+ let metadata = std::fs::metadata(&rollout).unwrap();
1430
+ let mut foreign = crate::state::abnormal_watch::observation_identity(&state, "w1").unwrap();
1431
+ foreign["team_key"] = serde_json::json!("another-team");
1432
+ state["coordinator"] = serde_json::json!({"abnormal_exit_watch": {"w1": {
1433
+ "watch_identity": foreign,
1434
+ "size": metadata.len(), "mtime_ns": metadata_mtime_ns(&metadata),
1435
+ "last_notified_key": "foreign", "last_check_key": "foreign",
1436
+ "last_error_observation_key": "foreign", "last_error_observation_cohort": "foreign"
1437
+ }}});
1438
+ let log = EventLog::new(&dir);
1439
+ let transport = crate::transport::test_support::OfflineTransport::new();
1440
+ detect_abnormal_exits(&dir, &transport, &mut state, &log, &[]).unwrap();
1441
+ let baseline = state["coordinator"]["abnormal_exit_watch"]["w1"].clone();
1442
+ assert_eq!(baseline["error_recency"], "stale");
1443
+ assert!(baseline.get("last_notified_key").is_none());
1444
+ assert!(crate::state::abnormal_watch::matches_observation(
1445
+ &state, "w1", &baseline
1446
+ ));
1447
+ let events = log.tail(0).unwrap();
1448
+ detect_abnormal_exits(&dir, &transport, &mut state, &log, &[]).unwrap();
1449
+ assert_eq!(
1450
+ log.tail(0).unwrap(),
1451
+ events,
1452
+ "unchanged baseline must not re-alarm"
1453
+ );
1454
+ assert!(find_event(&events, "worker.abnormal_exit").is_none());
1455
+ }
1456
+
1457
+ #[test]
1458
+ fn resource188_legacy_missing_cohort_still_observes_fresh_errors_and_dedupes() {
1459
+ let dir = temp_abnormal_dir("188-legacy-cohort");
1460
+ let rollout = dir.join("rollout-w1.jsonl");
1461
+ let error = "{\"method\":\"turn/completed\",\"params\":{\"turn\":{\"id\":\"old\",\"status\":\"failed\"}}}\n";
1462
+ std::fs::write(&rollout, error).unwrap();
1463
+ seed_abnormal_state(&dir, &rollout, "alive", 1);
1464
+ let mut state = crate::state::persist::load_runtime_state(&dir).unwrap();
1465
+ state["agents"]["w1"]
1466
+ .as_object_mut()
1467
+ .unwrap()
1468
+ .remove("spawn_epoch");
1469
+ let log = EventLog::new(&dir);
1470
+ let transport = crate::transport::test_support::OfflineTransport::new();
1471
+ detect_abnormal_exits(&dir, &transport, &mut state, &log, &[]).unwrap();
1472
+ assert_eq!(
1473
+ state["coordinator"]["abnormal_exit_watch"]["w1"]["error_recency"],
1474
+ "stale"
1475
+ );
1476
+ assert!(find_event(&log.tail(0).unwrap(), "worker.abnormal_exit").is_none());
1477
+ std::fs::OpenOptions::new()
1478
+ .append(true)
1479
+ .open(&rollout)
1480
+ .unwrap()
1481
+ .write_all(error.replace("old", "new-error").as_bytes())
1482
+ .unwrap();
1483
+ detect_abnormal_exits(&dir, &transport, &mut state, &log, &[]).unwrap();
1484
+ let events = log.tail(0).unwrap();
1485
+ assert_eq!(
1486
+ find_event(&events, "worker.abnormal_exit").unwrap()["error_recency"],
1487
+ "fresh"
1488
+ );
1489
+ let notified =
1490
+ state["coordinator"]["abnormal_exit_watch"]["w1"]["last_notified_key"].clone();
1491
+ assert!(notified.is_string());
1492
+ detect_abnormal_exits(&dir, &transport, &mut state, &log, &[]).unwrap();
1493
+ assert_eq!(log.tail(0).unwrap(), events);
1494
+ assert_eq!(
1495
+ state["coordinator"]["abnormal_exit_watch"]["w1"]["last_notified_key"],
1496
+ notified
1497
+ );
1498
+ state["agents"]["w1"]["spawn_epoch"] = serde_json::json!(2);
1499
+ detect_abnormal_exits(&dir, &transport, &mut state, &log, &[]).unwrap();
1500
+ let watch = &state["coordinator"]["abnormal_exit_watch"]["w1"];
1501
+ assert_eq!(
1502
+ watch["error_recency"], "stale",
1503
+ "a known generation must rebaseline unchanged bytes"
1504
+ );
1505
+ assert!(watch.get("last_notified_key").is_none());
1506
+ assert_eq!(
1507
+ log.tail(0)
1508
+ .unwrap()
1509
+ .iter()
1510
+ .filter(|event| event["event"] == "worker.abnormal_exit")
1511
+ .count(),
1512
+ 1
1513
+ );
1514
+ }
1515
+
1516
+ #[test]
1517
+ fn resource188_unknown_team_still_records_fresh_liveness_without_reusing_cache() {
1518
+ let dir = temp_abnormal_dir("188-unknown-team");
1519
+ let rollout = dir.join("rollout-w1.jsonl");
1520
+ std::fs::write(&rollout, "").unwrap();
1521
+ seed_abnormal_state(&dir, &rollout, "dead", 1);
1522
+ let mut state = crate::state::persist::load_runtime_state(&dir).unwrap();
1523
+ state.as_object_mut().unwrap().remove("active_team_key");
1524
+ let log = EventLog::new(&dir);
1525
+ let transport = crate::transport::test_support::OfflineTransport::new();
1526
+ detect_abnormal_exits(&dir, &transport, &mut state, &log, &[]).unwrap();
1527
+ assert_eq!(
1528
+ state["coordinator"]["abnormal_exit_watch"]["w1"]["last_liveness"],
1529
+ "dead"
1530
+ );
1531
+ assert!(state["coordinator"]["abnormal_exit_watch"]["w1"]
1532
+ .get("watch_identity")
1533
+ .is_none());
1534
+ }
1535
+
1416
1536
  #[test]
1417
1537
  fn abnormal_stale_error_baselines_then_fresh_alive_error_notifies() {
1418
1538
  let dir = temp_abnormal_dir("alive-fresh");
@@ -1412,13 +1412,6 @@ extern "C" fn managed_term_handler(_: libc::c_int) {
1412
1412
  }
1413
1413
 
1414
1414
  fn managed_attach_should_teardown(watch: &ManagedAttachWatch<'_>) -> bool {
1415
- let marker = crate::tmux_backend::leader_provider_exit_marker(watch.provider_label);
1416
- let target = Target::Pane(watch.spawned.pane_id.clone());
1417
- if let Ok(cap) = watch.transport.capture(&target, CaptureRange::Tail(80)) {
1418
- if cap.text.contains(&marker) || cap.text.contains("[team-agent] Provider exited") {
1419
- return true;
1420
- }
1421
- }
1422
1415
  matches!(
1423
1416
  leader_provider_health(
1424
1417
  watch.transport,
@@ -1674,65 +1667,21 @@ fn ensure_managed_provider_live_after_attach(
1674
1667
  )))
1675
1668
  }
1676
1669
 
1677
- /// 0.4.x (CR C-3 P0): leader provider health reconciliation. The default
1678
- /// `liveness()` check only proves the pane is ADDRESSABLE via tmux — it
1679
- /// returns `Live` even when the provider has exited and the wrapper shell
1680
- /// remains at its inert tail. This function distinguishes
1681
- /// `provider_alive` from `provider_exited` by:
1682
- /// 1. Reading `pane_current_command` (tmux `#{pane_current_command}`).
1683
- /// 2. If the current command matches the expected provider binary (or
1684
- /// one of its known aliases), report `Alive`.
1685
- /// 3. If the current command is a shell tail process AND the pane
1686
- /// content contains the exit marker `[team-agent] <provider> exited`
1687
- /// (emitted by `leader_shell_wrapper_command`), report
1688
- /// `ProviderExited`.
1689
- /// 4. Otherwise (Unknown shell, no marker), report `Alive` as the
1690
- /// conservative default — avoid false-positive exit alarms.
1691
- ///
1692
- /// Note: when the leader shell wrapper is used (CR C-2), a provider exit
1693
- /// leaves the pane as an inert `/bin/sh -c` tail with the exit marker in
1694
- /// scrollback. Pre-wrapper code that hit `exec claude` would have left the
1695
- /// pane as `[exited]` and `liveness()` would have returned `Dead`. The new
1696
- /// failure mode requires this richer health check to surface
1697
- /// `leader_provider_exited` as a distinct status.
1670
+ /// Reconcile real pane liveness with the supervising wrapper's exit receipt.
1671
+ /// Restored provider output may contain any exit-marker text; it is not process
1672
+ /// state and must never authorize destructive managed-window cleanup.
1698
1673
  pub fn leader_provider_health(
1699
1674
  transport: &dyn Transport,
1700
1675
  pane_id: &PaneId,
1701
- expected_provider_label: &str,
1676
+ _expected_provider_label: &str,
1702
1677
  ) -> LeaderProviderHealth {
1703
- use crate::transport::{CaptureRange, PaneField, Target};
1704
- let liveness = transport.liveness(pane_id).ok();
1705
- if matches!(liveness, Some(PaneLiveness::Dead)) {
1678
+ if matches!(transport.liveness(pane_id), Ok(PaneLiveness::Dead)) {
1706
1679
  return LeaderProviderHealth::Unreachable;
1707
1680
  }
1708
- let target = Target::Pane(pane_id.clone());
1709
- let current_command = transport
1710
- .query(&target, PaneField::PaneCurrentCommand)
1711
- .ok()
1712
- .flatten()
1713
- .map(|s| s.trim().to_lowercase())
1714
- .unwrap_or_default();
1715
- if !current_command.is_empty()
1716
- && (current_command == expected_provider_label
1717
- || current_command.contains(expected_provider_label))
1718
- {
1719
- return LeaderProviderHealth::Alive;
1681
+ if matches!(transport.provider_exit_status(pane_id), Ok(Some(_))) {
1682
+ return LeaderProviderHealth::ProviderExited;
1720
1683
  }
1721
- // Current command is NOT the provider — likely fell back to shell.
1722
- let is_shell = is_interactive_shell_basename(&current_command);
1723
- if is_shell {
1724
- // CR R6: marker text from single-source `leader_provider_exit_marker`
1725
- // so the wrapper printf and the health-check substring cannot drift.
1726
- let exit_marker_substr =
1727
- crate::tmux_backend::leader_provider_exit_marker(expected_provider_label);
1728
- if let Ok(cap) = transport.capture(&target, CaptureRange::Tail(200)) {
1729
- if cap.text.contains(&exit_marker_substr) {
1730
- return LeaderProviderHealth::ProviderExited;
1731
- }
1732
- }
1733
- }
1734
- // Conservative default — pane addressable, but couldn't positively
1735
- // confirm provider exit. Treat as Alive.
1684
+ // Missing/unsupported receipts (including old wrappers) are not proof of exit.
1736
1685
  LeaderProviderHealth::Alive
1737
1686
  }
1738
1687
 
@@ -1750,7 +1699,6 @@ pub enum LeaderProviderHealth {
1750
1699
 
1751
1700
  /// 0.4.x (CR R6 + R3): single-source shell-tail detection.
1752
1701
  /// Used by:
1753
- /// - `leader_provider_health` to decide "pane is at the shell tail"
1754
1702
  /// - shutdown logic to recognise a leader pane in shell-tail mode
1755
1703
  /// as still owned by the leader (not stray).
1756
1704
  ///
@@ -3001,6 +2949,7 @@ mod tests {
3001
2949
  screens: Mutex<Vec<String>>,
3002
2950
  sent: Mutex<Vec<(Target, Vec<Key>)>>,
3003
2951
  liveness: PaneLiveness,
2952
+ exit_status: Option<u8>,
3004
2953
  targets: Vec<PaneInfo>,
3005
2954
  window_options: Mutex<Vec<(String, String)>>,
3006
2955
  window_option_error: Mutex<Option<String>>,
@@ -3014,6 +2963,7 @@ mod tests {
3014
2963
  screens: Mutex::new(screens),
3015
2964
  sent: Mutex::new(Vec::new()),
3016
2965
  liveness: PaneLiveness::Unknown,
2966
+ exit_status: None,
3017
2967
  targets: Vec::new(),
3018
2968
  window_options: Mutex::new(Vec::new()),
3019
2969
  window_option_error: Mutex::new(None),
@@ -3027,6 +2977,7 @@ mod tests {
3027
2977
  screens: Mutex::new(Vec::new()),
3028
2978
  sent: Mutex::new(Vec::new()),
3029
2979
  liveness,
2980
+ exit_status: None,
3030
2981
  targets: Vec::new(),
3031
2982
  window_options: Mutex::new(Vec::new()),
3032
2983
  window_option_error: Mutex::new(None),
@@ -3040,6 +2991,7 @@ mod tests {
3040
2991
  screens: Mutex::new(Vec::new()),
3041
2992
  sent: Mutex::new(Vec::new()),
3042
2993
  liveness,
2994
+ exit_status: None,
3043
2995
  targets,
3044
2996
  window_options: Mutex::new(Vec::new()),
3045
2997
  window_option_error: Mutex::new(None),
@@ -3055,6 +3007,7 @@ mod tests {
3055
3007
  screens: Mutex::new(Vec::new()),
3056
3008
  sent: Mutex::new(Vec::new()),
3057
3009
  liveness: PaneLiveness::Unknown,
3010
+ exit_status: None,
3058
3011
  targets: Vec::new(),
3059
3012
  window_options: Mutex::new(Vec::new()),
3060
3013
  window_option_error: Mutex::new(Some(error.to_string())),
@@ -3186,6 +3139,10 @@ mod tests {
3186
3139
  Ok(self.liveness)
3187
3140
  }
3188
3141
 
3142
+ fn provider_exit_status(&self, _pane: &PaneId) -> Result<Option<u8>, TransportError> {
3143
+ Ok(self.exit_status)
3144
+ }
3145
+
3189
3146
  fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
3190
3147
  Ok(self.targets.clone())
3191
3148
  }
@@ -3859,22 +3816,37 @@ mod tests {
3859
3816
  }
3860
3817
 
3861
3818
  #[test]
3862
- fn managed_attach_teardown_detects_provider_exit_marker() {
3819
+ fn managed_attach_teardown_requires_exit_receipt_not_replayed_markers() {
3863
3820
  let spawned = managed_spawn_result();
3864
- let transport = ScriptedTransport::new(vec![
3865
- "[team-agent] claude exited with 1\n[team-agent] Provider exited; this pane no longer accepts input.".to_string(),
3821
+ let mut transport = ScriptedTransport::new(vec![
3822
+ "[team-agent] pi exited with 1\n[team-agent] Provider exited".to_string(),
3866
3823
  ]);
3867
- let watch = super::ManagedAttachWatch {
3868
- transport: &transport,
3869
- spawned: &spawned,
3870
- session: &spawned.session,
3871
- session_existed_before: false,
3872
- workspace: Path::new("/tmp"),
3873
- provider_label: "claude",
3874
- };
3875
- assert!(
3876
- super::managed_attach_should_teardown(&watch),
3877
- "exit marker in capture must trigger teardown"
3824
+ transport.liveness = PaneLiveness::Live;
3825
+ for exit_status in [None, Some(0), Some(17)] {
3826
+ transport.exit_status = exit_status;
3827
+ let watch = super::ManagedAttachWatch {
3828
+ transport: &transport,
3829
+ spawned: &spawned,
3830
+ session: &spawned.session,
3831
+ session_existed_before: false,
3832
+ workspace: Path::new("/tmp"),
3833
+ provider_label: "pi",
3834
+ };
3835
+ assert_eq!(
3836
+ super::managed_attach_should_teardown(&watch),
3837
+ exit_status.is_some()
3838
+ );
3839
+ }
3840
+ assert_eq!(
3841
+ transport.screens.lock().unwrap().len(),
3842
+ 1,
3843
+ "health must not read history"
3844
+ );
3845
+ transport.liveness = PaneLiveness::Dead;
3846
+ transport.exit_status = None;
3847
+ assert_eq!(
3848
+ super::leader_provider_health(&transport, &spawned.pane_id, "pi"),
3849
+ super::LeaderProviderHealth::Unreachable
3878
3850
  );
3879
3851
  }
3880
3852
 
@@ -151,14 +151,30 @@ pub(crate) fn parse_pi_list_models_table(bytes: &[u8]) -> Result<Vec<String>, Pr
151
151
  if line.trim().is_empty() {
152
152
  continue;
153
153
  }
154
- let columns = line.split_whitespace().collect::<Vec<_>>();
155
- if columns.len() < 2 || columns[0].is_empty() || columns[1].is_empty() {
156
- return Err(ProviderError::Command(format!(
154
+ let malformed = || {
155
+ ProviderError::Command(format!(
157
156
  "Pi model catalog row {} is malformed",
158
157
  index + 2
159
- )));
158
+ ))
159
+ };
160
+ let (provider, model) = line
161
+ .trim()
162
+ .split_once(char::is_whitespace)
163
+ .ok_or_else(malformed)?;
164
+ let mut model = model.trim();
165
+ // Pi prints the raw model id, which may contain spaces. Only the
166
+ // capability columns following it are single whitespace-free values.
167
+ for _ in 2..header_columns.len() {
168
+ model = model
169
+ .rsplit_once(char::is_whitespace)
170
+ .ok_or_else(malformed)?
171
+ .0
172
+ .trim_end();
173
+ }
174
+ if model.is_empty() {
175
+ return Err(malformed());
160
176
  }
161
- let exact = format!("{}/{}", columns[0], columns[1]);
177
+ let exact = format!("{provider}/{model}");
162
178
  if !seen.insert(exact.clone()) {
163
179
  return Err(ProviderError::Command(format!(
164
180
  "Pi model catalog contains duplicate exact id {exact:?}"
@@ -0,0 +1,9 @@
1
+ provider model context max-out thinking images
2
+ cursor claude-opus-4-7@1m:fast 1M 16.4K yes yes
3
+ cursor claude-opus-4-7@1m:slow 1M 16.4K yes yes
4
+ openai-codex gpt-5.6-luna 272K 128K yes yes
5
+ team-agent Claude Opus 4.6 (Thinking) 200K 32.8K yes yes
6
+ team-agent Claude Sonnet 4.6 (Thinking) 200K 32.8K yes yes
7
+ team-agent Gemini 3.8 Flash 200K 32.8K yes yes
8
+ team-agent gpt-5.6-luna 200K 32.8K yes yes
9
+ team-agent qwen3.8-27b 262.1K 32.8K yes yes
@@ -8,6 +8,77 @@ use crate::model::enums::ProviderEffort;
8
8
  use crate::provider::adapters::pi::{build_pi_command_argv, PiCommandRequest, PiSessionSelector};
9
9
 
10
10
  const CATALOG: &[u8] = include_bytes!("fixtures/pi_list_models_g0.stdout.txt");
11
+ // Public pi --list-models rows captured for issue #186, preserving column widths.
12
+ const SPACED_IDS_CATALOG: &[u8] = include_bytes!("fixtures/pi_list_models_spaced_ids.stdout.txt");
13
+
14
+ #[test]
15
+ fn pi_catalog_preserves_spaced_ids_and_qualified_slug_variants() {
16
+ let models = parse_pi_list_models_table(SPACED_IDS_CATALOG).expect("public catalog rows");
17
+ assert_eq!(
18
+ models,
19
+ vec![
20
+ "cursor/claude-opus-4-7@1m:fast",
21
+ "cursor/claude-opus-4-7@1m:slow",
22
+ "openai-codex/gpt-5.6-luna",
23
+ "team-agent/Claude Opus 4.6 (Thinking)",
24
+ "team-agent/Claude Sonnet 4.6 (Thinking)",
25
+ "team-agent/Gemini 3.8 Flash",
26
+ "team-agent/gpt-5.6-luna",
27
+ "team-agent/qwen3.8-27b",
28
+ ]
29
+ );
30
+ for exact in &models {
31
+ assert_eq!(select_exact_pi_model(&models, exact).unwrap(), *exact);
32
+ assert!(argv_has_pair(&command_for_model(exact), "--model", exact));
33
+ }
34
+ }
35
+
36
+ #[test]
37
+ fn pi_catalog_still_rejects_duplicate_complete_spaced_ids() {
38
+ let text = std::str::from_utf8(SPACED_IDS_CATALOG).unwrap();
39
+ let repeated = text
40
+ .lines()
41
+ .find(|line| line.contains("Claude Opus 4.6 (Thinking)"))
42
+ .unwrap();
43
+ let duplicate = format!("{text}{repeated}\n");
44
+ let error = parse_pi_list_models_table(duplicate.as_bytes()).unwrap_err();
45
+ assert!(error
46
+ .to_string()
47
+ .contains("duplicate exact id \"team-agent/Claude Opus 4.6 (Thinking)\""));
48
+ let exact = "team-agent/Claude Opus 4.6 (Thinking)";
49
+ assert!(select_exact_pi_model(&[exact.into(), exact.into()], exact).is_err());
50
+ }
51
+
52
+ #[test]
53
+ fn pi_catalog_preserves_internal_whitespace_with_compact_and_full_headers() {
54
+ for catalog in [
55
+ "provider model\r\nteam-agent\tClaude Opus 4.6 (Thinking) \r\n",
56
+ "provider model context max-out thinking images\r\nteam-agent\tClaude Opus 4.6 (Thinking)\t200K 32.8K\tyes yes \r\n",
57
+ ] {
58
+ assert_eq!(
59
+ parse_pi_list_models_table(catalog.as_bytes()).unwrap(),
60
+ vec!["team-agent/Claude Opus 4.6 (Thinking)"]
61
+ );
62
+ }
63
+ }
64
+
65
+ #[test]
66
+ fn pi_catalog_rejects_rows_without_model_or_required_trailing_fields() {
67
+ for row in [
68
+ "team-agent",
69
+ "team-agent 200K 32.8K yes yes",
70
+ "team-agent gpt-5.6-luna 200K 32.8K yes",
71
+ ] {
72
+ let catalog = format!("provider model context max-out thinking images\n{row}\n");
73
+ assert!(
74
+ parse_pi_list_models_table(catalog.as_bytes())
75
+ .unwrap_err()
76
+ .to_string()
77
+ .contains("row 2 is malformed"),
78
+ "missing model or capability fields must refuse: {row}"
79
+ );
80
+ }
81
+ }
11
82
 
12
83
  fn argv_has_pair(argv: &[String], flag: &str, value: &str) -> bool {
13
84
  argv.windows(2)