@team-agent/installer 0.5.44 → 0.5.46

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.
Files changed (28) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/emit.rs +44 -33
  4. package/crates/team-agent/src/cli/mod.rs +6 -0
  5. package/crates/team-agent/src/cli/named_address.rs +206 -4
  6. package/crates/team-agent/src/cli/send.rs +105 -2
  7. package/crates/team-agent/src/cli/spec.rs +1 -1
  8. package/crates/team-agent/src/cli/status_port.rs +70 -2
  9. package/crates/team-agent/src/cli/tests/run_delegation.rs +7 -4
  10. package/crates/team-agent/src/coordinator/steps/abnormal.rs +14 -22
  11. package/crates/team-agent/src/lifecycle/restart/agent.rs +103 -27
  12. package/crates/team-agent/src/lifecycle/restart/common.rs +136 -0
  13. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +168 -0
  14. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +7 -7
  15. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +1 -1
  16. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +3 -0
  17. package/crates/team-agent/src/lifecycle/types.rs +3 -0
  18. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +10 -0
  19. package/crates/team-agent/src/mcp_server/tools.rs +67 -8
  20. package/crates/team-agent/src/model/mod.rs +6 -0
  21. package/crates/team-agent/src/model/name_similarity.rs +266 -0
  22. package/crates/team-agent/src/provider/session/capture.rs +113 -22
  23. package/crates/team-agent/src/provider/session_scan/codex.rs +51 -4
  24. package/crates/team-agent/src/tmux_backend/tests.rs +116 -10
  25. package/crates/team-agent/src/tmux_backend.rs +5 -16
  26. package/crates/team-agent/src/transport/tests/wire.rs +6 -0
  27. package/crates/team-agent/src/transport.rs +8 -2
  28. package/package.json +4 -4
@@ -428,6 +428,18 @@ fn restart_with_selected_team_and_transport(
428
428
  let live_worker_session_deferred_teardown =
429
429
  session_live_or_default(transport, &session_name, false)
430
430
  && !collect_live_agents_from_state(&state).is_empty();
431
+ let same_role_cohort_targets =
432
+ same_role_cohort_targets_from_decisions(&state, spec_workspace, &plan.decisions);
433
+ let pre_spawn_pane_ids = if live_worker_session_deferred_teardown {
434
+ transport
435
+ .list_targets()
436
+ .map_err(|error| LifecycleError::Transport(error.to_string()))?
437
+ .into_iter()
438
+ .map(|pane| pane.pane_id.as_str().to_string())
439
+ .collect::<std::collections::BTreeSet<_>>()
440
+ } else {
441
+ std::collections::BTreeSet::new()
442
+ };
431
443
  if session_live_or_default(transport, &session_name, false)
432
444
  && !live_worker_session_deferred_teardown
433
445
  {
@@ -779,6 +791,46 @@ fn restart_with_selected_team_and_transport(
779
791
  );
780
792
  }
781
793
  // END_B5_RESTART_ISOLATION_LOOP
794
+ if live_worker_session_deferred_teardown {
795
+ let successful_agent_ids = successful_agents
796
+ .iter()
797
+ .map(|agent| agent.agent_id.as_str())
798
+ .collect::<std::collections::BTreeSet<_>>();
799
+ let old_successful_targets = same_role_cohort_targets
800
+ .iter()
801
+ .filter(|target| successful_agent_ids.contains(target.agent_id.as_str()))
802
+ .cloned()
803
+ .collect::<Vec<_>>();
804
+ let successful_targets =
805
+ same_role_cohort_targets_from_decisions(&state, spec_workspace, &successful_agents);
806
+ let actual_spawn_panes = actual_successful_restart_spawn_panes(
807
+ transport,
808
+ &session_name,
809
+ &successful_targets,
810
+ &pre_spawn_pane_ids,
811
+ )?;
812
+ if let Some(error) =
813
+ actual_restart_spawn_binding_error(&successful_targets, &actual_spawn_panes)
814
+ {
815
+ cleanup_actual_restart_spawns(transport, &actual_spawn_panes);
816
+ return Err(LifecycleError::RequirementUnmet(error));
817
+ }
818
+ if let Err(error) =
819
+ retire_expected_same_role_cohorts(transport, "restart", &old_successful_targets)
820
+ {
821
+ cleanup_actual_restart_spawns(transport, &actual_spawn_panes);
822
+ return Err(LifecycleError::RequirementUnmet(error));
823
+ }
824
+ if let Some(error) = same_role_cohort_exactly_one_error(
825
+ transport,
826
+ &session_name,
827
+ "restart",
828
+ &successful_targets,
829
+ ) {
830
+ cleanup_actual_restart_spawns(transport, &actual_spawn_panes);
831
+ return Err(LifecycleError::RequirementUnmet(error));
832
+ }
833
+ }
782
834
  let mut topology_authority_agent_ids = successful_agents
783
835
  .iter()
784
836
  .map(|agent| agent.agent_id.as_str().to_string())
@@ -2090,6 +2142,122 @@ fn mark_fake_harness_agent_respawned(
2090
2142
  agent.insert("owner_team_id".to_string(), serde_json::json!(team_key));
2091
2143
  }
2092
2144
 
2145
+ fn same_role_cohort_targets_from_decisions(
2146
+ state: &serde_json::Value,
2147
+ spec_workspace: &Path,
2148
+ agents: &[RestartedAgent],
2149
+ ) -> Vec<SameRoleCohortTarget> {
2150
+ let mut targets = Vec::new();
2151
+ for restarted in agents {
2152
+ let agent_id = &restarted.agent_id;
2153
+ let raw_agent = state
2154
+ .get("agents")
2155
+ .and_then(|agents| agents.get(agent_id.as_str()));
2156
+ let effective_agent = raw_agent.map(|agent| {
2157
+ rehydrate_agent_command_context_from_spec(spec_workspace, agent_id, agent)
2158
+ });
2159
+ if effective_agent
2160
+ .as_ref()
2161
+ .and_then(|agent| agent.get("provider"))
2162
+ .and_then(serde_json::Value::as_str)
2163
+ .is_some_and(|provider| provider.eq_ignore_ascii_case("fake"))
2164
+ {
2165
+ continue;
2166
+ }
2167
+ let window = raw_agent
2168
+ .and_then(|agent| agent.get("window"))
2169
+ .and_then(serde_json::Value::as_str)
2170
+ .filter(|value| !value.is_empty())
2171
+ .unwrap_or_else(|| agent_id.as_str());
2172
+ if is_per_agent_cohort_window(window, agent_id) {
2173
+ let expected_pane_id = raw_agent
2174
+ .and_then(|agent| agent.get("pane_id"))
2175
+ .and_then(serde_json::Value::as_str)
2176
+ .filter(|value| !value.is_empty());
2177
+ targets.push(
2178
+ SameRoleCohortTarget::new(agent_id, window).with_expected_pane_id(expected_pane_id),
2179
+ );
2180
+ }
2181
+ }
2182
+ targets
2183
+ }
2184
+
2185
+ fn actual_successful_restart_spawn_panes(
2186
+ transport: &dyn crate::transport::Transport,
2187
+ session_name: &SessionName,
2188
+ targets: &[SameRoleCohortTarget],
2189
+ pre_spawn_pane_ids: &std::collections::BTreeSet<String>,
2190
+ ) -> Result<Vec<crate::transport::PaneInfo>, LifecycleError> {
2191
+ let windows = targets
2192
+ .iter()
2193
+ .map(|target| target.window.as_str())
2194
+ .collect::<std::collections::BTreeSet<_>>();
2195
+ let panes = transport
2196
+ .list_targets()
2197
+ .map_err(|error| LifecycleError::Transport(error.to_string()))?
2198
+ .into_iter()
2199
+ .filter(|pane| !pre_spawn_pane_ids.contains(pane.pane_id.as_str()))
2200
+ .filter(|pane| pane.session.as_str() == session_name.as_str())
2201
+ .filter(|pane| {
2202
+ pane.window_name
2203
+ .as_ref()
2204
+ .is_some_and(|window| windows.contains(window.as_str()))
2205
+ })
2206
+ .filter(|pane| {
2207
+ transport
2208
+ .liveness(&pane.pane_id)
2209
+ .is_ok_and(|state| state == crate::transport::PaneLiveness::Live)
2210
+ })
2211
+ .collect();
2212
+ Ok(panes)
2213
+ }
2214
+
2215
+ fn actual_restart_spawn_binding_error(
2216
+ targets: &[SameRoleCohortTarget],
2217
+ actual_spawn_panes: &[crate::transport::PaneInfo],
2218
+ ) -> Option<String> {
2219
+ let mut mismatches = Vec::new();
2220
+ for target in targets {
2221
+ let observed = actual_spawn_panes
2222
+ .iter()
2223
+ .filter(|pane| {
2224
+ pane.window_name
2225
+ .as_ref()
2226
+ .is_some_and(|window| window.as_str() == target.window)
2227
+ })
2228
+ .map(|pane| pane.pane_id.as_str().to_string())
2229
+ .collect::<Vec<_>>();
2230
+ if observed.len() != 1
2231
+ || target.expected_pane_id.as_deref() != observed.first().map(String::as_str)
2232
+ {
2233
+ mismatches.push(format!(
2234
+ "{}:window={}:expected_pane={}:actual_spawn_panes=[{}]",
2235
+ target.agent_id,
2236
+ target.window,
2237
+ target.expected_pane_id.as_deref().unwrap_or("<missing>"),
2238
+ observed.join(",")
2239
+ ));
2240
+ }
2241
+ }
2242
+ if mismatches.is_empty() {
2243
+ None
2244
+ } else {
2245
+ Some(format!(
2246
+ "restart refused: spawn identity/binding mismatch; {}",
2247
+ mismatches.join("; ")
2248
+ ))
2249
+ }
2250
+ }
2251
+
2252
+ fn cleanup_actual_restart_spawns(
2253
+ transport: &dyn crate::transport::Transport,
2254
+ actual_spawn_panes: &[crate::transport::PaneInfo],
2255
+ ) {
2256
+ for pane in actual_spawn_panes {
2257
+ let _ = transport.kill_pane(&pane.pane_id);
2258
+ }
2259
+ }
2260
+
2093
2261
  fn write_fake_harness_spawn_argv_event(
2094
2262
  workspace: &Path,
2095
2263
  decision: &RestartedAgent,
@@ -402,6 +402,11 @@ case "$*" in
402
402
  ;;
403
403
  esac
404
404
  case "$*" in
405
+ *"new-window "*|*"new-session "*)
406
+ : > "$spawned_marker"
407
+ printf '%%9288\n'
408
+ exit 0
409
+ ;;
405
410
  *"display-message -p -t $pane #{pane_id}"*)
406
411
  if [ -f "$killed_marker" ]; then
407
412
  echo "can't find pane: $pane" >&2
@@ -410,11 +415,6 @@ case "$*" in
410
415
  printf '%s\n' "$pane"
411
416
  exit 0
412
417
  ;;
413
- *"display-message -p -t $session:alpha #{pane_id}"*)
414
- : > "$spawned_marker"
415
- printf '%%9288\n'
416
- exit 0
417
- ;;
418
418
  *"list-windows -t $session -F #{window_name}"*)
419
419
  exit 0
420
420
  ;;
@@ -873,8 +873,8 @@ fn reset_agent_stop_not_proven_grep_guard_hard_gate_wired() {
873
873
  "reset_agent_at_paths must emit reset_agent.stop_not_proven via the writer"
874
874
  );
875
875
  assert!(
876
- contents.contains("if !agent_is_paused && !stop.stopped"),
877
- "gate must run only when stop.stopped=false (the dangerous case)"
876
+ contents.contains("if !agent_is_paused {"),
877
+ "gate must run after stop for every non-paused reset, including stop.stopped=true residue"
878
878
  );
879
879
  assert!(
880
880
  contents.contains("list_same_role_panes") && contents.contains("is_per_agent_window"),
@@ -744,7 +744,7 @@ impl Transport for BRealTransport {
744
744
  if matches!(self.mode, BRealMode::Misowned) {
745
745
  targets.push(pane_info("team-phaseb", "w2", "%5", 502));
746
746
  }
747
- if matches!(self.mode, BRealMode::Owned) {
747
+ if matches!(self.mode, BRealMode::Owned) && !self.spawns.lock().unwrap().is_empty() {
748
748
  targets.push(pane_info("team-phaseb", "w1", "%10", 501));
749
749
  }
750
750
  Ok(targets)
@@ -553,6 +553,9 @@ fn normalize_value(value: Value, ctx: &mut NormalizeCtx, key: Option<&str>) -> V
553
553
  let sorted = map.into_iter().collect::<BTreeMap<_, _>>();
554
554
  let mut out = Map::new();
555
555
  for (child_key, child) in sorted {
556
+ if matches!(child_key.as_str(), "model_source" | "provider_source") {
557
+ continue;
558
+ }
556
559
  out.insert(
557
560
  child_key.clone(),
558
561
  normalize_value(child, ctx, Some(child_key.as_str())),
@@ -632,6 +632,9 @@ pub enum ResetAgentOutcome {
632
632
  discarded_session_id: Option<SessionId>,
633
633
  session_id: Option<SessionId>,
634
634
  new_session_id: Option<SessionId>,
635
+ capture_state: String,
636
+ reset_proof: String,
637
+ weak_reset_warning: Option<String>,
635
638
  },
636
639
  /// 未传 discard_session → 拒绝(不丢上下文的误用保护)。
637
640
  Refused { reason: ResetRefusal },
@@ -67,6 +67,9 @@ pub(crate) fn reset_agent(
67
67
  discarded_session_id,
68
68
  session_id,
69
69
  new_session_id,
70
+ capture_state,
71
+ reset_proof,
72
+ weak_reset_warning,
70
73
  } => Ok(ToolOk {
71
74
  fields: object_fields(serde_json::json!({
72
75
  "ok": true,
@@ -78,6 +81,13 @@ pub(crate) fn reset_agent(
78
81
  "discarded_session_id": discarded_session_id.as_ref().map(|id| id.as_str()),
79
82
  "session_id": session_id.as_ref().map(|id| id.as_str()),
80
83
  "new_session_id": new_session_id.as_ref().map(|id| id.as_str()),
84
+ "capture_state": match capture_state.as_str() {
85
+ "captured" => "captured",
86
+ "attribution_ambiguous" => "attribution_ambiguous",
87
+ _ => "transcript_missing",
88
+ },
89
+ "reset_proof": if reset_proof == "weak" { "weak" } else { "strong" },
90
+ "weak_reset_warning": weak_reset_warning,
81
91
  })),
82
92
  }),
83
93
  ResetAgentOutcome::Refused { reason } => Ok(ToolOk {
@@ -737,24 +737,83 @@ impl TeamOrchestratorTools {
737
737
  {
738
738
  return None;
739
739
  }
740
- if let Ok(visible) = self.get_visible_peers() {
741
- if visible.peers.iter().any(|peer| peer.as_str() == target) {
742
- return None;
743
- }
740
+ // 0.5.45 naming-addressing (design §3.6, RED-2 MCP): compute
741
+ // the visible peers ONCE and reuse for both exact-match check
742
+ // AND scope-safe candidate ranking. Peers come from
743
+ // `get_visible_peers()` (owner-team only) so sibling teams,
744
+ // host registry, and workspace reverse scans cannot leak.
745
+ let visible_peers: Vec<String> = self
746
+ .get_visible_peers()
747
+ .map(|visible| {
748
+ visible
749
+ .peers
750
+ .iter()
751
+ .map(|peer| peer.as_str().to_string())
752
+ .collect()
753
+ })
754
+ .unwrap_or_default();
755
+ if visible_peers.iter().any(|peer| peer == target) {
756
+ return None;
744
757
  }
745
- let hint = "the requested peer is not part of your team; worker-origin MCP cannot widen team scope.";
758
+ let owner_team_key = owner_team
759
+ .as_ref()
760
+ .map(TeamKey::as_str)
761
+ .unwrap_or("")
762
+ .to_string();
763
+ // Rank the typo against owner-scope peers.
764
+ let ranked = {
765
+ use crate::model::name_similarity::{rank, Candidate};
766
+ let candidates: Vec<Candidate<String>> = visible_peers
767
+ .iter()
768
+ .map(|peer| Candidate {
769
+ match_key: peer.clone(),
770
+ stable_key: peer.clone(),
771
+ payload: peer.clone(),
772
+ })
773
+ .collect();
774
+ rank(target, &candidates)
775
+ };
776
+ let candidate_values: Vec<Value> = ranked
777
+ .iter()
778
+ .map(|peer| {
779
+ serde_json::json!({
780
+ "name": peer,
781
+ "team_key": owner_team_key,
782
+ "agent_id": peer,
783
+ "advisory": true,
784
+ })
785
+ })
786
+ .collect();
787
+ let best = ranked.first().cloned();
788
+ let hint = if let Some(best) = best.as_deref() {
789
+ format!(
790
+ "the requested peer is not in your owner team; did you mean `{best}`?"
791
+ )
792
+ } else {
793
+ "the requested peer is not part of your team; worker-origin MCP cannot widen team scope.".to_string()
794
+ };
746
795
  let _ = EventLog::new(&self.workspace).write(
747
796
  "mcp.send_message_refused",
748
797
  serde_json::json!({
749
798
  "reason": "peer_not_in_scope",
750
- "sender_team_id": owner_team.as_ref().map(TeamKey::as_str).unwrap_or(""),
799
+ "sender_team_id": owner_team_key,
751
800
  "scope": "team",
752
- "hint": hint
801
+ "hint": hint,
753
802
  }),
754
803
  );
755
804
  let mut extra = serde_json::Map::new();
756
805
  extra.insert("status".to_string(), Value::String("refused".to_string()));
757
- extra.insert("hint".to_string(), Value::String(hint.to_string()));
806
+ extra.insert("hint".to_string(), Value::String(hint.clone()));
807
+ // 0.5.45 naming-addressing (design §3.6, RED-2 MCP): additive
808
+ // scope-safe suggestions inside the existing ToolError.extra
809
+ // JSON envelope — no struct/schema change, no event log
810
+ // widening. Advisory only: `isError=true` and
811
+ // reason=peer_not_in_scope stay unchanged.
812
+ extra.insert("requested_name".to_string(), Value::String(target.clone()));
813
+ if let Some(best) = best {
814
+ extra.insert("suggested_name".to_string(), Value::String(best));
815
+ }
816
+ extra.insert("candidates".to_string(), Value::Array(candidate_values));
758
817
  Some(ToolError {
759
818
  reason: ToolErrorReason::PeerNotInScope,
760
819
  exc_type: "PeerNotInScope".to_string(),
@@ -12,6 +12,12 @@
12
12
  pub mod enums;
13
13
  pub mod errors;
14
14
  pub mod ids;
15
+ // 0.5.45 naming-addressing (design §3.2/§4.1): shared pure name
16
+ // similarity ranking helper. crate-private consumer set = cli/emit.rs
17
+ // (subcommand hint), cli/named_address.rs (typo candidates),
18
+ // cli/send.rs (positional adapter), mcp_server/tools.rs (owner-scoped
19
+ // peer suggestions). Never a routing authority — advisory only.
20
+ pub(crate) mod name_similarity;
15
21
  pub mod paths;
16
22
  pub mod permissions;
17
23
  pub mod routing;
@@ -0,0 +1,266 @@
1
+ //! 0.5.45 naming-addressing (design §3.2/§4.1) — shared pure name
2
+ //! similarity ranking helper.
3
+ //!
4
+ //! **Pure**: no filesystem reads, no state loads, no terminal
5
+ //! multiplexer calls, no message dispatch. The RED-6 governance guard
6
+ //! scans this file for the forbidden import tokens and rejects any
7
+ //! occurrence.
8
+ //!
9
+ //! **Single source of truth for name-similarity ranking**: consumed by
10
+ //! - `cli/emit.rs::nearest_subcommand` (unknown top-level subcommand hint),
11
+ //! - `cli/named_address.rs` (bare/`--to-name` typo candidates),
12
+ //! - `cli/send.rs` positional adapter (unknown short id in selected team),
13
+ //! - `mcp_server/tools.rs` owner-scoped peer suggestions.
14
+ //!
15
+ //! **Never a routing authority**: the returned ranking is *advisory
16
+ //! only*. Callers must still exit 1 / `isError=true` / refuse the
17
+ //! request on their original reason; the ranked payload only
18
+ //! populates the additive `requested_name` / `suggested_name` /
19
+ //! `candidates` fields that get echoed back to the user.
20
+ //!
21
+ //! Ranking rules (design §3.2, byte-locked by RED-6):
22
+ //! 1. Comparison keys are trimmed and ASCII-lowercased.
23
+ //! 2. Candidates whose `match_key` prefixes the request outrank
24
+ //! lower-distance non-prefix candidates.
25
+ //! 3. Otherwise sort by Levenshtein distance.
26
+ //! 4. Threshold widens with request length: `0..=3 → 1`, `4..=6 →
27
+ //! 2`, longer → `3`. Nothing beyond the threshold is returned —
28
+ //! no "best guess for a far name".
29
+ //! 5. Sort order: `prefix_miss` (bool), `distance`, `stable_key`.
30
+ //! 6. Deduplicate on `stable_key`, then cap at 3 results.
31
+ //! 7. Canonical spelling comes from the candidate `name` payload
32
+ //! (not the lowercased comparison key) so case is preserved.
33
+
34
+ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
35
+
36
+ use std::collections::BTreeSet;
37
+
38
+ /// A candidate to rank. `T` is a caller-owned payload returned
39
+ /// verbatim in the ranked output — callers use it to keep whatever
40
+ /// side data (team_key, workspace, etc.) they need to reconstruct
41
+ /// their advisory JSON envelope.
42
+ #[derive(Debug, Clone)]
43
+ pub struct Candidate<T> {
44
+ /// Comparison key. Callers pass the raw canonical name; this
45
+ /// function trims + lowercases before comparing.
46
+ pub match_key: String,
47
+ /// Stable tie-break key. Typically the same as `match_key`, but
48
+ /// callers with cross-team or cross-workspace candidates use a
49
+ /// composite (`team/agent`) so ordering is deterministic across
50
+ /// otherwise-tied entries.
51
+ pub stable_key: String,
52
+ /// Caller-owned payload returned verbatim.
53
+ pub payload: T,
54
+ }
55
+
56
+ /// Threshold for accepting a candidate given the request length.
57
+ /// Byte-locked by RED-6: short requests get 1, medium 2, longer 3.
58
+ fn threshold_for_len(len: usize) -> usize {
59
+ match len {
60
+ 0..=3 => 1,
61
+ 4..=6 => 2,
62
+ _ => 3,
63
+ }
64
+ }
65
+
66
+ /// Rank `candidates` against `request`. Returns up to 3 payloads,
67
+ /// most-similar-first. `<request>` casing does not affect matching,
68
+ /// only comparison; the caller-supplied payload (which carries the
69
+ /// canonical `name`) is what surfaces to the user.
70
+ ///
71
+ /// Empty return means "no acceptable candidate" — do NOT re-rank or
72
+ /// synthesize a best-guess elsewhere.
73
+ pub fn rank<T: Clone>(request: &str, candidates: &[Candidate<T>]) -> Vec<T> {
74
+ let request_key = normalize(request);
75
+ if request_key.is_empty() {
76
+ return Vec::new();
77
+ }
78
+ let threshold = threshold_for_len(request_key.chars().count());
79
+ let mut seen: BTreeSet<String> = BTreeSet::new();
80
+ let mut scored: Vec<(bool, usize, String, T)> = Vec::new();
81
+ for candidate in candidates {
82
+ if !seen.insert(candidate.stable_key.clone()) {
83
+ continue;
84
+ }
85
+ let match_key = normalize(&candidate.match_key);
86
+ if match_key.is_empty() {
87
+ continue;
88
+ }
89
+ let distance = levenshtein(&request_key, &match_key);
90
+ // Prefix hit means "request is a prefix of candidate" OR
91
+ // "candidate is a prefix of request" — either direction
92
+ // beats a same-distance edit anywhere in the middle.
93
+ let is_prefix = match_key.starts_with(&request_key) || request_key.starts_with(&match_key);
94
+ // Prefix candidates skip the distance threshold (a 1-char
95
+ // prefix miss with distance 5 is still a legitimate
96
+ // completion hint); non-prefix candidates must clear the
97
+ // threshold.
98
+ if !is_prefix && distance > threshold {
99
+ continue;
100
+ }
101
+ // Prefix-miss is the primary sort key; sorting bools puts
102
+ // false (prefix hit) before true (prefix miss).
103
+ scored.push((
104
+ !is_prefix,
105
+ distance,
106
+ candidate.stable_key.clone(),
107
+ candidate.payload.clone(),
108
+ ));
109
+ }
110
+ scored.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)).then(a.2.cmp(&b.2)));
111
+ scored.into_iter().take(3).map(|(_, _, _, p)| p).collect()
112
+ }
113
+
114
+ /// Convenience wrapper for the common case where callers only need
115
+ /// the single best canonical name (a `String`), not full payloads.
116
+ /// Returns `None` when no candidate clears the threshold.
117
+ pub fn best_name<'a, S: AsRef<str>>(request: &str, candidates: &'a [S]) -> Option<&'a str> {
118
+ let payloads: Vec<Candidate<&'a str>> = candidates
119
+ .iter()
120
+ .map(|c| {
121
+ let name = c.as_ref();
122
+ Candidate {
123
+ match_key: name.to_string(),
124
+ stable_key: name.to_string(),
125
+ payload: name,
126
+ }
127
+ })
128
+ .collect();
129
+ rank(request, &payloads).into_iter().next()
130
+ }
131
+
132
+ fn normalize(input: &str) -> String {
133
+ input.trim().to_ascii_lowercase()
134
+ }
135
+
136
+ /// Standard Levenshtein edit distance. Pure, no dependencies.
137
+ /// Compared strings must already be normalized (trim + lowercase) by
138
+ /// the caller (`rank` does this internally).
139
+ pub fn levenshtein(a: &str, b: &str) -> usize {
140
+ let a: Vec<char> = a.chars().collect();
141
+ let b: Vec<char> = b.chars().collect();
142
+ let mut prev: Vec<usize> = (0..=b.len()).collect();
143
+ let mut curr = vec![0usize; b.len() + 1];
144
+ for (i, &ca) in a.iter().enumerate() {
145
+ curr[0] = i + 1;
146
+ for (j, &cb) in b.iter().enumerate() {
147
+ let cost = if ca == cb { 0 } else { 1 };
148
+ curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
149
+ }
150
+ std::mem::swap(&mut prev, &mut curr);
151
+ }
152
+ prev[b.len()]
153
+ }
154
+
155
+ #[cfg(test)]
156
+ mod tests {
157
+ use super::*;
158
+
159
+ fn c(name: &str) -> Candidate<String> {
160
+ Candidate {
161
+ match_key: name.to_string(),
162
+ stable_key: name.to_string(),
163
+ payload: name.to_string(),
164
+ }
165
+ }
166
+
167
+ #[test]
168
+ fn levenshtein_basic() {
169
+ assert_eq!(levenshtein("kitten", "sitting"), 3);
170
+ assert_eq!(levenshtein("", ""), 0);
171
+ assert_eq!(levenshtein("abc", "abc"), 0);
172
+ assert_eq!(levenshtein("abc", "abd"), 1);
173
+ }
174
+
175
+ #[test]
176
+ fn prefix_hit_beats_lower_distance_non_prefix() {
177
+ // "stat" is a prefix of "status" (distance 2) but "slat" is
178
+ // distance 1 (mid-word substitution). Prefix wins.
179
+ let out = rank("stat", &[c("slat"), c("status")]);
180
+ assert_eq!(out, vec!["status".to_string(), "slat".to_string()]);
181
+ }
182
+
183
+ #[test]
184
+ fn distance_orders_non_prefix() {
185
+ let out = rank("btea", &[c("bota"), c("beta")]);
186
+ assert_eq!(out[0], "beta"); // 1 edit
187
+ assert_eq!(out[1], "bota"); // 2 edits
188
+ }
189
+
190
+ #[test]
191
+ fn stable_key_decides_tie() {
192
+ // Both "beta" and "bota" are distance 2 from "bata".
193
+ let out = rank("bata", &[c("bota"), c("beta")]);
194
+ assert_eq!(out, vec!["beta".to_string(), "bota".to_string()]);
195
+ }
196
+
197
+ #[test]
198
+ fn caps_at_three() {
199
+ let out = rank("aaaa", &[c("aaab"), c("aaac"), c("aaad"), c("aaae")]);
200
+ assert_eq!(out.len(), 3);
201
+ assert_eq!(out, vec!["aaab", "aaac", "aaad"]);
202
+ }
203
+
204
+ #[test]
205
+ fn case_preserved_in_payload() {
206
+ // "BTEA" normalized matches "Beta" (distance 1); the
207
+ // canonical payload preserves original casing.
208
+ let out = rank("BTEA", &[c("Beta")]);
209
+ assert_eq!(out, vec!["Beta".to_string()]);
210
+ }
211
+
212
+ #[test]
213
+ fn far_request_returns_empty() {
214
+ let out = rank("zzzzzz", &[c("beta")]);
215
+ assert!(out.is_empty());
216
+ }
217
+
218
+ #[test]
219
+ fn threshold_widens_with_length() {
220
+ assert_eq!(threshold_for_len(0), 1);
221
+ assert_eq!(threshold_for_len(3), 1);
222
+ assert_eq!(threshold_for_len(4), 2);
223
+ assert_eq!(threshold_for_len(6), 2);
224
+ assert_eq!(threshold_for_len(7), 3);
225
+ assert_eq!(threshold_for_len(20), 3);
226
+ }
227
+
228
+ #[test]
229
+ fn empty_request_returns_empty() {
230
+ let out = rank("", &[c("beta")]);
231
+ assert!(out.is_empty());
232
+ }
233
+
234
+ #[test]
235
+ fn dedup_on_stable_key() {
236
+ let out = rank(
237
+ "beta",
238
+ &[
239
+ c("beta"),
240
+ Candidate {
241
+ match_key: "beta".to_string(),
242
+ stable_key: "beta".to_string(),
243
+ payload: "beta-dup".to_string(),
244
+ },
245
+ ],
246
+ );
247
+ assert_eq!(out, vec!["beta".to_string()]);
248
+ }
249
+
250
+ #[test]
251
+ fn best_name_convenience() {
252
+ assert_eq!(best_name("btea", &["beta", "bota"]), Some("beta"));
253
+ assert_eq!(best_name("zzzzzz", &["beta"]), None);
254
+ }
255
+
256
+ #[test]
257
+ fn statu_still_maps_to_status() {
258
+ // Byte-lock the existing emit.rs behavior: `statu` → `status`
259
+ // with the shared helper.
260
+ let out = rank(
261
+ "statu",
262
+ &[c("status"), c("send"), c("start"), c("restart")],
263
+ );
264
+ assert_eq!(out[0], "status");
265
+ }
266
+ }