@team-agent/installer 0.5.113 → 0.5.114

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 (46) hide show
  1. package/Cargo.lock +252 -84
  2. package/Cargo.toml +3 -1
  3. package/crates/team-agent/Cargo.toml +3 -0
  4. package/crates/team-agent/src/cli/adapters.rs +28 -15
  5. package/crates/team-agent/src/cli/diagnose.rs +36 -13
  6. package/crates/team-agent/src/cli/emit.rs +55 -20
  7. package/crates/team-agent/src/cli/leader.rs +16 -0
  8. package/crates/team-agent/src/cli/leader_prompt.rs +261 -0
  9. package/crates/team-agent/src/cli/mod.rs +152 -8
  10. package/crates/team-agent/src/cli/models.rs +68 -18
  11. package/crates/team-agent/src/cli/spec.rs +7 -1
  12. package/crates/team-agent/src/cli/status_port/brief.rs +268 -101
  13. package/crates/team-agent/src/cli/tests/base.rs +88 -0
  14. package/crates/team-agent/src/cli/tests/status_send.rs +2 -0
  15. package/crates/team-agent/src/compiler.rs +66 -18
  16. package/crates/team-agent/src/contract_runtime/backend.rs +646 -0
  17. package/crates/team-agent/src/contract_runtime/config.rs +309 -0
  18. package/crates/team-agent/src/contract_runtime/discovery.rs +237 -0
  19. package/crates/team-agent/src/contract_runtime/forward.rs +229 -0
  20. package/crates/team-agent/src/contract_runtime/framework.rs +261 -0
  21. package/crates/team-agent/src/contract_runtime/mcp.rs +396 -0
  22. package/crates/team-agent/src/contract_runtime/mod.rs +14 -0
  23. package/crates/team-agent/src/contract_runtime/prompt.rs +216 -0
  24. package/crates/team-agent/src/contract_runtime/pump.rs +207 -0
  25. package/crates/team-agent/src/contract_runtime/registry.rs +30 -0
  26. package/crates/team-agent/src/coordinator/tick.rs +26 -8
  27. package/crates/team-agent/src/leader/mod.rs +1 -0
  28. package/crates/team-agent/src/leader/prompt.rs +906 -0
  29. package/crates/team-agent/src/leader/start.rs +260 -16
  30. package/crates/team-agent/src/leader/tests/mod.rs +2 -0
  31. package/crates/team-agent/src/leader/tests/prompt_contract.rs +268 -0
  32. package/crates/team-agent/src/leader/types.rs +6 -0
  33. package/crates/team-agent/src/lib.rs +4 -0
  34. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +472 -388
  35. package/crates/team-agent/src/lifecycle/launch/spawn.rs +38 -9
  36. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +17 -0
  37. package/crates/team-agent/src/lifecycle/launch.rs +39 -28
  38. package/crates/team-agent/src/lifecycle/types.rs +4 -1
  39. package/crates/team-agent/src/lifecycle/worker_command_context.rs +37 -27
  40. package/crates/team-agent/src/main.rs +6 -0
  41. package/crates/team-agent/src/messaging/delivery.rs +50 -1
  42. package/crates/team-agent/src/messaging/send.rs +10 -0
  43. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +131 -0
  44. package/crates/team-agent/src/model/spec.rs +24 -12
  45. package/crates/team-agent/src/state/persist.rs +73 -0
  46. package/package.json +4 -4
@@ -0,0 +1,309 @@
1
+ //! Read-only per-agent routing/preflight. Contract roles opt in independently;
2
+ //! other roles keep the legacy engine, including in the same logical team.
3
+ use std::collections::BTreeSet;
4
+ use std::path::{Path, PathBuf};
5
+
6
+ use serde::{Deserialize, Serialize};
7
+ use team_agent_contract::contract::{
8
+ session::CwdIdentity,
9
+ types::{ProviderId, ScopeId, SeatId},
10
+ };
11
+ use team_agent_contract::host::{digest, digest_hex, process::resolve_cwd};
12
+ use thiserror::Error;
13
+
14
+ use super::prompt::{self, Prompt, Source};
15
+ use crate::cli::QuickStartArgs;
16
+ use crate::communication_mode::CommunicationMode;
17
+ use crate::compiler::split_front_matter;
18
+ use crate::model::yaml::Value;
19
+
20
+ #[derive(Debug, Error)]
21
+ pub enum ConfigError {
22
+ #[error("invalid contract configuration field {field} in {path:?}")]
23
+ Invalid { path: PathBuf, field: &'static str },
24
+ #[error("contract runtime capability is not available: {0}")]
25
+ Unsupported(&'static str),
26
+ #[error("contract configuration I/O: {0}")]
27
+ Io(#[from] std::io::Error),
28
+ }
29
+
30
+ // Prompts intentionally have no Debug implementation and are never diagnostics.
31
+ #[derive(Clone, Serialize, Deserialize)]
32
+ pub struct RoleConfig {
33
+ pub id: SeatId,
34
+ pub provider: ProviderId,
35
+ pub source: PathBuf,
36
+ pub role: String,
37
+ pub model: String,
38
+ pub effort: Option<String>,
39
+ pub bypass: bool,
40
+ pub body: String,
41
+ pub prompt: Prompt,
42
+ }
43
+
44
+ #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
45
+ pub enum RuntimeFamily {
46
+ Legacy,
47
+ Contract,
48
+ }
49
+
50
+ #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
51
+ pub struct MemberRoute {
52
+ pub id: String,
53
+ pub provider: String,
54
+ pub source: PathBuf,
55
+ pub runtime: RuntimeFamily,
56
+ }
57
+
58
+ #[derive(Clone, Serialize, Deserialize)]
59
+ pub struct TeamConfig {
60
+ pub selector: String,
61
+ pub label: String,
62
+ pub scope: ScopeId,
63
+ pub team_dir: PathBuf,
64
+ pub workspace: CwdIdentity,
65
+ pub objective: String,
66
+ pub members: Vec<MemberRoute>,
67
+ /// Only contract roles; the legacy member definitions are not rewritten.
68
+ pub roles: Vec<RoleConfig>,
69
+ }
70
+
71
+ fn invalid(path: &Path, field: &'static str) -> ConfigError {
72
+ ConfigError::Invalid {
73
+ path: path.into(),
74
+ field,
75
+ }
76
+ }
77
+ fn text(meta: &Value, field: &'static str, path: &Path) -> Result<Option<String>, ConfigError> {
78
+ match meta.get(field) {
79
+ None | Some(Value::Null) => Ok(None),
80
+ Some(Value::Str(value)) if !value.trim().is_empty() && !value.contains('\0') => {
81
+ Ok(Some(value.clone()))
82
+ }
83
+ _ => Err(invalid(path, field)),
84
+ }
85
+ }
86
+ fn required(meta: &Value, field: &'static str, path: &Path) -> Result<String, ConfigError> {
87
+ text(meta, field, path)?.ok_or_else(|| invalid(path, field))
88
+ }
89
+ fn mode(meta: &Value, path: &Path) -> Result<Option<CommunicationMode>, ConfigError> {
90
+ text(meta, "communication_mode", path)?
91
+ .map(|value| {
92
+ CommunicationMode::parse(&value).ok_or_else(|| invalid(path, "communication_mode"))
93
+ })
94
+ .transpose()
95
+ }
96
+
97
+ /// A malformed explicit Kiro header cannot fall back to the default Pi worker.
98
+ fn malformed_kiro_header(body: &str) -> bool {
99
+ body.strip_prefix("---\n").is_some_and(|header| {
100
+ header
101
+ .lines()
102
+ .take_while(|line| *line != "---")
103
+ .any(|line| {
104
+ line.split_once(':').is_some_and(|(key, value)| {
105
+ key.trim() == "provider"
106
+ && super::registry::recognizes(
107
+ value
108
+ .split('#')
109
+ .next()
110
+ .unwrap_or("")
111
+ .trim()
112
+ .trim_matches(['\'', '"']),
113
+ )
114
+ })
115
+ })
116
+ })
117
+ }
118
+
119
+ /// None delegates exactly to the existing no-Kiro/leader-only path. No native
120
+ /// command, model probe, config write or process is created by this selection.
121
+ pub fn read_team(args: &QuickStartArgs) -> Result<Option<TeamConfig>, ConfigError> {
122
+ read_team_with_user_source(args, prompt::user_source)
123
+ }
124
+
125
+ /// A unified framework user-policy source can be injected without making the
126
+ /// provider know its storage location. Tests use this port instead of real HOME.
127
+ pub fn read_team_with_user_source(
128
+ args: &QuickStartArgs,
129
+ user_source: impl FnOnce() -> Result<Option<Source>, ConfigError>,
130
+ ) -> Result<Option<TeamConfig>, ConfigError> {
131
+ let agents = args.agents_dir.join("agents");
132
+ let entries = match std::fs::read_dir(&agents) {
133
+ Ok(entries) => entries,
134
+ Err(_) => return Ok(None),
135
+ };
136
+ let mut paths = Vec::new();
137
+ for entry in entries {
138
+ let entry = entry?;
139
+ let path = entry.path();
140
+ if path.extension().and_then(|s| s.to_str()) == Some("md") {
141
+ paths.push(path);
142
+ }
143
+ }
144
+ paths.sort();
145
+ let mut roles = Vec::new();
146
+ let mut selected = false;
147
+ for path in paths {
148
+ let raw = match std::fs::read_to_string(&path) {
149
+ Ok(raw) => raw,
150
+ Err(error) if selected => return Err(error.into()),
151
+ Err(_) => return Ok(None), // let the legacy compiler report its existing I/O error
152
+ };
153
+ let (meta, body) = split_front_matter(&raw);
154
+ let normalized = raw.replace("\r\n", "\n").replace('\r', "\n");
155
+ if meta.get("provider").is_none() && malformed_kiro_header(&normalized) {
156
+ return Err(invalid(&path, "front matter"));
157
+ }
158
+ if meta
159
+ .get("provider")
160
+ .and_then(Value::as_str)
161
+ .is_some_and(super::registry::recognizes)
162
+ {
163
+ selected = true;
164
+ }
165
+ roles.push((path, raw, meta, body));
166
+ }
167
+ if !selected {
168
+ return Ok(None);
169
+ }
170
+ if args
171
+ .backend
172
+ .as_deref()
173
+ .is_some_and(|backend| backend != "tmux")
174
+ {
175
+ return Err(ConfigError::Unsupported(
176
+ "Kiro contract transport requires tmux",
177
+ ));
178
+ }
179
+ let team_dir = args.agents_dir.canonicalize()?;
180
+ let team_path = team_dir.join("TEAM.md");
181
+ let team_source = Source::read(&team_path)?;
182
+ let (team_meta, team_body) = split_front_matter(&team_source.contents);
183
+ let label = args
184
+ .name
185
+ .clone()
186
+ .or(text(&team_meta, "name", &team_path)?)
187
+ .or_else(|| {
188
+ team_dir
189
+ .file_name()
190
+ .and_then(|s| s.to_str())
191
+ .map(str::to_owned)
192
+ })
193
+ .ok_or_else(|| invalid(&team_path, "name"))?;
194
+ let selector = args.team_id.clone().unwrap_or_else(|| label.clone());
195
+ if selector.trim().is_empty() || selector.chars().any(char::is_control) {
196
+ return Err(invalid(&team_path, "team selector"));
197
+ }
198
+ let workspace =
199
+ resolve_cwd(&args.workspace).map_err(|_| invalid(&args.workspace, "workspace identity"))?;
200
+ let key =
201
+ serde_json::to_vec(&(&workspace, &selector)).map_err(|_| invalid(&team_path, "scope"))?;
202
+ let scope = ScopeId::new(format!("contract-{}", &digest_hex(digest(&key))[..32]))
203
+ .map_err(|_| invalid(&team_path, "scope"))?;
204
+ let objective = text(&team_meta, "objective", &team_path)?.unwrap_or(team_body);
205
+ let user = user_source()?;
206
+ let project = prompt::project_source(&workspace.path)?;
207
+ let team_instructions = prompt::team_sources(&team_source, &team_meta)?;
208
+ let team_mode = mode(&team_meta, &team_path)?.unwrap_or_default();
209
+ let mut ids = BTreeSet::new();
210
+ let mut compiled = Vec::new();
211
+ let mut members = Vec::new();
212
+ for (source, raw, meta, body) in roles {
213
+ let id = text(&meta, "agent_id", &source)?
214
+ .or(text(&meta, "name", &source)?)
215
+ .or_else(|| {
216
+ source
217
+ .file_stem()
218
+ .and_then(|s| s.to_str())
219
+ .map(str::to_owned)
220
+ })
221
+ .ok_or_else(|| invalid(&source, "agent id"))?;
222
+ if !ids.insert(id.clone()) {
223
+ return Err(invalid(&source, "duplicate agent id"));
224
+ }
225
+ let provider = text(&meta, "provider", &source)?.unwrap_or_else(|| "pi".into());
226
+ let registration = super::registry::descriptor(&provider);
227
+ if registration.is_none() && super::registry::recognizes(&provider) {
228
+ return Err(invalid(&source, "provider"));
229
+ }
230
+ let runtime = if registration.is_some() {
231
+ RuntimeFamily::Contract
232
+ } else {
233
+ RuntimeFamily::Legacy
234
+ };
235
+ members.push(MemberRoute {
236
+ id: id.clone(),
237
+ provider,
238
+ source: source.canonicalize()?,
239
+ runtime,
240
+ });
241
+ let Some(registration) = registration else {
242
+ continue;
243
+ };
244
+ let provider =
245
+ ProviderId::new(registration.identity.id).map_err(|_| invalid(&source, "provider"))?;
246
+ let id = SeatId::new(id).map_err(|_| invalid(&source, "agent id"))?;
247
+ if id.as_str() == "leader" {
248
+ return Err(invalid(&source, "reserved worker id"));
249
+ }
250
+ if text(&meta, "auth_mode", &source)?.is_some_and(|value| value != "subscription")
251
+ || text(&meta, "profile", &source)?.is_some()
252
+ {
253
+ return Err(ConfigError::Unsupported(
254
+ "Kiro uses an existing native subscription; API/profile mappings are unavailable",
255
+ ));
256
+ }
257
+ let bypass = match meta.get("dangerously_skip_permissions") {
258
+ None => false,
259
+ Some(Value::Bool(value)) => *value,
260
+ _ => return Err(invalid(&source, "dangerously_skip_permissions")),
261
+ };
262
+ if body.trim().is_empty() || body.contains('\0') {
263
+ return Err(invalid(&source, "role body"));
264
+ }
265
+ if ["args", "cli_args"]
266
+ .iter()
267
+ .any(|field| meta.get(field).is_some())
268
+ {
269
+ return Err(ConfigError::Unsupported(
270
+ "unregistered native arguments cannot override the managed launch plan",
271
+ ));
272
+ }
273
+ let role = text(&meta, "role", &source)?.unwrap_or_else(|| id.as_str().into());
274
+ let document = Source {
275
+ path: source.canonicalize()?,
276
+ contents: raw,
277
+ };
278
+ let prompt = prompt::assemble(
279
+ id.as_str(),
280
+ &role,
281
+ mode(&meta, &source)?.unwrap_or(team_mode),
282
+ user.as_ref(),
283
+ project.as_ref(),
284
+ &document,
285
+ &team_instructions,
286
+ )?;
287
+ compiled.push(RoleConfig {
288
+ id,
289
+ provider,
290
+ source: document.path,
291
+ role,
292
+ model: required(&meta, "model", &source)?,
293
+ effort: text(&meta, "effort", &source)?,
294
+ bypass,
295
+ body,
296
+ prompt,
297
+ });
298
+ }
299
+ Ok(Some(TeamConfig {
300
+ selector,
301
+ label,
302
+ scope,
303
+ team_dir,
304
+ workspace,
305
+ objective,
306
+ members,
307
+ roles: compiled,
308
+ }))
309
+ }
@@ -0,0 +1,237 @@
1
+ //! Bounded native discovery. Nothing here creates a runtime or claims T2/T3.
2
+ use std::path::{Path, PathBuf};
3
+
4
+ use team_agent_contract::contract::{hooks::*, plan::CatalogObservation, types::*};
5
+ use team_agent_contract::host::{command::RealCommandRunner, process::resolve_cwd};
6
+ use team_agent_contract::kiro::{native::*, KiroAdapter, McpStdio, KIRO_DESCRIPTOR};
7
+ use thiserror::Error;
8
+
9
+ #[derive(Debug, Error)]
10
+ pub enum DiscoveryError {
11
+ #[error("required executable is unavailable: {0}")]
12
+ Executable(&'static str),
13
+ #[error("native discovery failed: {0}")]
14
+ Host(#[from] team_agent_contract::host::HostError),
15
+ #[error("native engine probe failed: {0}")]
16
+ Probe(#[from] EngineProbeError),
17
+ #[error("native discovery contract: {0:?}")]
18
+ Contract(ContractError),
19
+ #[error("native model discovery: {0:?}")]
20
+ Catalog(ReadFailure),
21
+ #[error("native discovery I/O: {0}")]
22
+ Io(#[from] std::io::Error),
23
+ }
24
+
25
+ pub struct Discovery {
26
+ pub engine: PathBuf,
27
+ pub native: NativeIdentity,
28
+ pub catalog: CatalogObservation,
29
+ }
30
+
31
+ pub fn executable(name: &'static str) -> Result<PathBuf, DiscoveryError> {
32
+ let path = std::env::var_os("PATH").ok_or(DiscoveryError::Executable(name))?;
33
+ for directory in std::env::split_paths(&path) {
34
+ let candidate = directory.join(name);
35
+ let Ok(metadata) = std::fs::metadata(&candidate) else {
36
+ continue;
37
+ };
38
+ use std::os::unix::fs::PermissionsExt;
39
+ if metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 {
40
+ return Ok(candidate.canonicalize()?);
41
+ }
42
+ }
43
+ Err(DiscoveryError::Executable(name))
44
+ }
45
+
46
+ pub fn discover(workspace: &Path) -> Result<Discovery, DiscoveryError> {
47
+ let home = std::env::var_os("HOME")
48
+ .map(PathBuf::from)
49
+ .ok_or(DiscoveryError::Executable(
50
+ "HOME for native installation lookup",
51
+ ))?;
52
+ let engine = resolve_helper(&executable(KIRO_DESCRIPTOR.identity.binary)?, &home)?;
53
+ let native = probe_engine(
54
+ &engine,
55
+ &mut RealCommandRunner::default(),
56
+ discovery_bounds(),
57
+ )?;
58
+ let request = CatalogRequest {
59
+ provider: ProviderId::new(KIRO_DESCRIPTOR.identity.id).map_err(DiscoveryError::Contract)?,
60
+ native: native.clone(),
61
+ executable: engine.clone(),
62
+ cwd: resolve_cwd(workspace)?,
63
+ source: CHAT_CATALOG_SOURCE,
64
+ bounds: discovery_bounds(),
65
+ };
66
+ let mut host = CatalogReader::new(request.clone(), RealCommandRunner::default())
67
+ .map_err(DiscoveryError::Contract)?;
68
+ // H1 does not use these pointers; keep the actual current executable and do
69
+ // not manufacture an alternate CLI or execute an MCP server for discovery.
70
+ let adapter = KiroAdapter::new(McpStdio {
71
+ executable: std::env::current_exe()?.canonicalize()?,
72
+ arguments: Vec::new(),
73
+ environment: Default::default(),
74
+ })
75
+ .map_err(DiscoveryError::Contract)?;
76
+ let catalog = adapter
77
+ .discover(&request, &mut host)
78
+ .map_err(DiscoveryError::Catalog)?;
79
+ Ok(Discovery {
80
+ engine,
81
+ native,
82
+ catalog,
83
+ })
84
+ }
85
+
86
+ pub fn models(
87
+ args: &crate::cli::ModelsArgs,
88
+ ) -> Result<crate::cli::CmdResult, crate::cli::CliError> {
89
+ let result = std::env::current_dir()
90
+ .map_err(DiscoveryError::Io)
91
+ .and_then(|cwd| discover(&cwd));
92
+ render_models(args, result)
93
+ }
94
+
95
+ fn render_models(
96
+ args: &crate::cli::ModelsArgs,
97
+ result: Result<Discovery, DiscoveryError>,
98
+ ) -> Result<crate::cli::CmdResult, crate::cli::CliError> {
99
+ use crate::cli::{CmdOutput, CmdResult, ExitCode};
100
+ use serde_json::json;
101
+ let (value, human, exit) = match result {
102
+ Ok(discovery) => {
103
+ let query = args.search.as_deref().map(str::to_lowercase);
104
+ let rows: Vec<_> = discovery.catalog.models.iter()
105
+ .filter(|model| query.as_ref().is_none_or(|query| model.id.to_lowercase().contains(query)))
106
+ .map(|model| {
107
+ let efforts = match &model.efforts {
108
+ Support::Supported(values) => json!({"state":"supported","values":values.iter().map(|value| value.as_str()).collect::<Vec<_>>()}),
109
+ Support::Unsupported(reason) => json!({"state":"unsupported","reason":reason.code}),
110
+ Support::Unverified(reason) => json!({"state":"unverified","reason":reason.code}),
111
+ };
112
+ json!({"provider":args.provider,"model_id":model.id,"role_model":model.id,"aliases":[],"efforts":efforts})
113
+ }).collect();
114
+ let value = json!({"schema_version":"models.v1","ok":true,"provider":args.provider,
115
+ "models":rows,"auth":"unknown","auth_basis":"native_catalog_visibility_is_not_authorization",
116
+ "source":{"schema":discovery.catalog.schema,"executable":discovery.engine,
117
+ "version":discovery.native.version,"executable_sha256":team_agent_contract::host::digest_hex(discovery.native.executable_sha256)},
118
+ "current_role_model":null,"search":args.search});
119
+ let mut lines = vec![format!("models.v1 | {} native model IDs:", args.provider)];
120
+ lines.extend(
121
+ rows.iter()
122
+ .filter_map(|row| row["model_id"].as_str())
123
+ .map(str::to_owned),
124
+ );
125
+ lines.push("Catalog visibility does not prove subscription authorization, runtime model selection, or effort support.".into());
126
+ (value, lines.join("\n"), ExitCode::Ok)
127
+ }
128
+ Err(error) => {
129
+ let message = error.to_string();
130
+ let action = "Check the selected native executable and existing subscription; no model fallback or login retry was performed.";
131
+ (
132
+ json!({"schema_version":"models.v1","ok":false,"provider":args.provider,"models":[],"auth":"not_ready","error":message,"action":action}),
133
+ format!("models.v1 | {message}\n{action}"),
134
+ ExitCode::Error,
135
+ )
136
+ }
137
+ };
138
+ Ok(CmdResult {
139
+ output: CmdOutput::Human(if args.json {
140
+ serde_json::to_string_pretty(&value)?
141
+ } else {
142
+ human
143
+ }),
144
+ exit,
145
+ as_json: false,
146
+ preserve_json_order: true,
147
+ })
148
+ }
149
+
150
+ #[cfg(test)]
151
+ mod tests {
152
+ use super::*;
153
+ use crate::cli::{CmdOutput, ExitCode, ModelsArgs};
154
+ use team_agent_contract::contract::plan::ModelRecord;
155
+
156
+ #[test]
157
+ fn native_catalog_projection_preserves_ids_without_claiming_auth_or_effort() {
158
+ let native = NativeIdentity {
159
+ version: "controlled".into(),
160
+ harness: "v3".into(),
161
+ ui: "tui".into(),
162
+ platform: Platform::Linux,
163
+ executable_sha256: Digest([1; 32]),
164
+ };
165
+ let discovery = Discovery {
166
+ engine: "/controlled/kiro-cli-chat".into(),
167
+ native: native.clone(),
168
+ catalog: CatalogObservation {
169
+ provider: ProviderId::new("kiro").unwrap(),
170
+ native,
171
+ schema: "controlled-json".into(),
172
+ models: vec![ModelRecord {
173
+ id: "Exact-Case-ID".into(),
174
+ efforts: Support::Unverified(Reason {
175
+ code: "not-observed",
176
+ message: "No effort evidence",
177
+ }),
178
+ }],
179
+ },
180
+ };
181
+ let args = ModelsArgs {
182
+ provider: "kiro".into(),
183
+ search: Some("exact".into()),
184
+ json: true,
185
+ };
186
+ let result = render_models(&args, Ok(discovery)).unwrap();
187
+ assert_eq!(result.exit, ExitCode::Ok);
188
+ let CmdOutput::Human(text) = result.output else {
189
+ panic!("JSON text projection");
190
+ };
191
+ let json: serde_json::Value = serde_json::from_str(&text).unwrap();
192
+ assert_eq!(json["auth"], "unknown");
193
+ assert_eq!(json["models"][0]["model_id"], "Exact-Case-ID");
194
+ assert_eq!(json["models"][0]["efforts"]["state"], "unverified");
195
+ assert_eq!(json["source"]["schema"], "controlled-json");
196
+ assert!(json["current_role_model"].is_null());
197
+ }
198
+
199
+ #[test]
200
+ fn version_failure_projection_preserves_the_received_version() {
201
+ let args = ModelsArgs {
202
+ provider: "kiro".into(),
203
+ search: None,
204
+ json: true,
205
+ };
206
+ let error = parse_version(b"kiro-cli-chat 2.29.invalid\n").unwrap_err();
207
+ let result = render_models(&args, Err(DiscoveryError::Probe(error))).unwrap();
208
+ assert_eq!(result.exit, ExitCode::Error);
209
+ let CmdOutput::Human(text) = result.output else {
210
+ panic!("JSON text projection");
211
+ };
212
+ let json: serde_json::Value = serde_json::from_str(&text).unwrap();
213
+ assert!(json["error"]
214
+ .as_str()
215
+ .unwrap()
216
+ .contains("kiro-cli-chat 2.29.invalid"));
217
+ assert_eq!(json["models"], serde_json::json!([]));
218
+ }
219
+
220
+ #[test]
221
+ fn native_discovery_failure_has_no_catalog_or_fallback() {
222
+ let args = ModelsArgs {
223
+ provider: "kiro".into(),
224
+ search: None,
225
+ json: true,
226
+ };
227
+ let result = render_models(&args, Err(DiscoveryError::Executable("kiro-cli"))).unwrap();
228
+ assert_eq!(result.exit, ExitCode::Error);
229
+ let CmdOutput::Human(text) = result.output else {
230
+ panic!("JSON text projection");
231
+ };
232
+ let json: serde_json::Value = serde_json::from_str(&text).unwrap();
233
+ assert_eq!(json["ok"], false);
234
+ assert_eq!(json["models"], serde_json::json!([]));
235
+ assert!(json["error"].as_str().unwrap().contains("kiro-cli"));
236
+ }
237
+ }