@team-agent/installer 0.5.107 → 0.5.109
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 +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +1 -1
- package/crates/team-agent/src/cli/emit.rs +3 -3
- package/crates/team-agent/src/cli/mod.rs +87 -4
- package/crates/team-agent/src/cli/models.rs +252 -3
- package/crates/team-agent/src/cli/spec.rs +1 -1
- package/crates/team-agent/src/cli/status_port/brief.rs +379 -70
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +184 -0
- package/crates/team-agent/src/cli/tests/status_brief.rs +624 -0
- package/crates/team-agent/src/provider/model_catalog.rs +343 -5
- package/crates/team-agent/src/provider/testdata/cursor-list-models-native.stdout +250 -0
- package/crates/team-agent/src/provider/testdata/grok-models-authenticated.stdout +9 -0
- package/crates/team-agent/src/provider/testdata/grok-models-unauthenticated.stdout +7 -0
- package/crates/team-agent/src/tmux_backend.rs +99 -2
- package/package.json +4 -4
|
@@ -80,10 +80,11 @@ impl CatalogError {
|
|
|
80
80
|
"cursor_agent" => "agent",
|
|
81
81
|
"codex" => "codex",
|
|
82
82
|
"claude" | "claude_code" => "claude",
|
|
83
|
+
"grok" => "grok",
|
|
83
84
|
_ => "provider CLI",
|
|
84
85
|
};
|
|
85
86
|
match self {
|
|
86
|
-
Self::UnsupportedProvider => "choose a supported provider: pi, cursor_agent, codex, claude, or
|
|
87
|
+
Self::UnsupportedProvider => "choose a supported provider: pi, cursor_agent, codex, claude, claude_code, or grok".into(),
|
|
87
88
|
Self::ExecutableUnavailable => format!("install or repair the PATH-first `{executable}` executable, then retry `team-agent models --provider {provider}`"),
|
|
88
89
|
Self::UnsupportedSchema => format!("upgrade `{executable}` to a version that supports its model catalog protocol, then retry `team-agent models --provider {provider}`"),
|
|
89
90
|
Self::CommandFailed => format!("run `{executable}` directly to check catalog/account access, then retry `team-agent models --provider {provider}`"),
|
|
@@ -118,6 +119,7 @@ fn display_provider(provider: &str) -> &str {
|
|
|
118
119
|
"cursor_agent" => "Cursor",
|
|
119
120
|
"codex" => "Codex",
|
|
120
121
|
"claude" | "claude_code" => "Claude",
|
|
122
|
+
"grok" => "Grok",
|
|
121
123
|
_ => provider,
|
|
122
124
|
}
|
|
123
125
|
}
|
|
@@ -130,6 +132,7 @@ pub fn parse_catalog_provider(name: &str) -> Option<Provider> {
|
|
|
130
132
|
"codex" => Some(Provider::Codex),
|
|
131
133
|
"claude" => Some(Provider::Claude),
|
|
132
134
|
"claude_code" => Some(Provider::ClaudeCode),
|
|
135
|
+
"grok" => Some(Provider::Grok),
|
|
133
136
|
_ => None,
|
|
134
137
|
}
|
|
135
138
|
}
|
|
@@ -153,6 +156,7 @@ enum CatalogSource {
|
|
|
153
156
|
Pi,
|
|
154
157
|
CursorAgent,
|
|
155
158
|
Codex,
|
|
159
|
+
Grok,
|
|
156
160
|
Claude,
|
|
157
161
|
}
|
|
158
162
|
|
|
@@ -163,7 +167,8 @@ fn catalog_source(provider: Provider) -> Option<CatalogSource> {
|
|
|
163
167
|
Provider::CursorAgent => Some(CatalogSource::CursorAgent),
|
|
164
168
|
Provider::Codex => Some(CatalogSource::Codex),
|
|
165
169
|
Provider::Claude | Provider::ClaudeCode => Some(CatalogSource::Claude),
|
|
166
|
-
Provider::
|
|
170
|
+
Provider::Grok => Some(CatalogSource::Grok),
|
|
171
|
+
Provider::Copilot | Provider::GeminiCli | Provider::Fake => None,
|
|
167
172
|
}
|
|
168
173
|
}
|
|
169
174
|
|
|
@@ -184,6 +189,10 @@ pub fn discover_model_catalog(provider_name: &str) -> Result<Vec<ModelRecord>, C
|
|
|
184
189
|
let bytes = run_native("codex", &["debug", "models"], None, None, &[], CATALOG_TIMEOUT, MAX_CATALOG_BYTES, &mut NoopObserver)?;
|
|
185
190
|
parse_codex_catalog(&bytes, requested_provider)?
|
|
186
191
|
}
|
|
192
|
+
CatalogSource::Grok => {
|
|
193
|
+
let bytes = run_native("grok", &["models"], None, None, &[], CATALOG_TIMEOUT, MAX_CATALOG_BYTES, &mut NoopObserver)?;
|
|
194
|
+
parse_grok_catalog(&bytes)?
|
|
195
|
+
}
|
|
187
196
|
CatalogSource::Claude => discover_claude_catalog(requested_provider)?,
|
|
188
197
|
};
|
|
189
198
|
Ok(records)
|
|
@@ -242,8 +251,20 @@ pub(crate) fn parse_pi_catalog(bytes: &[u8]) -> Result<Vec<ModelRecord>, Catalog
|
|
|
242
251
|
|
|
243
252
|
pub(crate) fn parse_cursor_catalog(bytes: &[u8]) -> Result<Vec<ModelRecord>, CatalogError> {
|
|
244
253
|
let text = std::str::from_utf8(bytes).map_err(|_| CatalogError::InvalidUtf8)?;
|
|
254
|
+
let lines = text.lines().collect::<Vec<_>>();
|
|
255
|
+
let tip_line = lines
|
|
256
|
+
.iter()
|
|
257
|
+
.rposition(|line| !line.trim().is_empty())
|
|
258
|
+
.filter(|index| {
|
|
259
|
+
lines[*index]
|
|
260
|
+
.strip_prefix("Tip: use --model ")
|
|
261
|
+
.is_some_and(|tip| !tip.is_empty())
|
|
262
|
+
});
|
|
245
263
|
let mut records: Vec<ModelRecord> = Vec::new();
|
|
246
|
-
for line in
|
|
264
|
+
for (index, line) in lines.into_iter().enumerate() {
|
|
265
|
+
if Some(index) == tip_line {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
247
268
|
let line = line.trim();
|
|
248
269
|
if line.is_empty() || line.trim_end_matches(':').eq_ignore_ascii_case("available models") { continue; }
|
|
249
270
|
let Some((id, display)) = line.split_once(" - ") else { return Err(CatalogError::Malformed); };
|
|
@@ -260,6 +281,117 @@ pub(crate) fn parse_cursor_catalog(bytes: &[u8]) -> Result<Vec<ModelRecord>, Cat
|
|
|
260
281
|
Ok(records)
|
|
261
282
|
}
|
|
262
283
|
|
|
284
|
+
fn parse_grok_catalog(bytes: &[u8]) -> Result<Vec<ModelRecord>, CatalogError> {
|
|
285
|
+
let text = std::str::from_utf8(bytes).map_err(|_| CatalogError::InvalidUtf8)?;
|
|
286
|
+
let mut has_auth_notice = false;
|
|
287
|
+
let mut has_models_header = false;
|
|
288
|
+
let mut declared_default: Option<String> = None;
|
|
289
|
+
let mut starred_default: Option<String> = None;
|
|
290
|
+
let mut marked_default: Option<String> = None;
|
|
291
|
+
let mut seen = HashSet::new();
|
|
292
|
+
let mut records = Vec::new();
|
|
293
|
+
|
|
294
|
+
for line in text.lines() {
|
|
295
|
+
let line = line.trim();
|
|
296
|
+
if line.is_empty() {
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if !has_models_header {
|
|
300
|
+
if matches!(line, "You are logged in with grok.com." | "You are not authenticated.") {
|
|
301
|
+
if has_auth_notice {
|
|
302
|
+
return Err(CatalogError::Malformed);
|
|
303
|
+
}
|
|
304
|
+
has_auth_notice = true;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if let Some(id) = line.strip_prefix("Default model: ") {
|
|
308
|
+
if declared_default.is_some() || id.is_empty() {
|
|
309
|
+
return Err(CatalogError::Malformed);
|
|
310
|
+
}
|
|
311
|
+
validate_identity(id)?;
|
|
312
|
+
declared_default = Some(id.to_string());
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if line == "Available models:" {
|
|
316
|
+
has_models_header = true;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
return Err(CatalogError::Malformed);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
let (is_starred, row) = if let Some(row) = line.strip_prefix("- ") {
|
|
323
|
+
(false, row)
|
|
324
|
+
} else if let Some(row) = line.strip_prefix("* ") {
|
|
325
|
+
(true, row)
|
|
326
|
+
} else {
|
|
327
|
+
return Err(CatalogError::Malformed);
|
|
328
|
+
};
|
|
329
|
+
if row == "(default)" {
|
|
330
|
+
return Err(CatalogError::Malformed);
|
|
331
|
+
}
|
|
332
|
+
let (id, has_default_marker) = row
|
|
333
|
+
.strip_suffix(" (default)")
|
|
334
|
+
.map_or((row, false), |id| (id, true));
|
|
335
|
+
if id.is_empty() {
|
|
336
|
+
return Err(CatalogError::Malformed);
|
|
337
|
+
}
|
|
338
|
+
validate_identity(id)?;
|
|
339
|
+
if !seen.insert(id.to_string()) {
|
|
340
|
+
return Err(CatalogError::DuplicateIdentity(id.to_string()));
|
|
341
|
+
}
|
|
342
|
+
if is_starred {
|
|
343
|
+
if starred_default.as_deref().is_some_and(|current| current != id) {
|
|
344
|
+
return Err(CatalogError::Malformed);
|
|
345
|
+
}
|
|
346
|
+
starred_default = Some(id.to_string());
|
|
347
|
+
}
|
|
348
|
+
if has_default_marker {
|
|
349
|
+
if marked_default.as_deref().is_some_and(|current| current != id) {
|
|
350
|
+
return Err(CatalogError::Malformed);
|
|
351
|
+
}
|
|
352
|
+
marked_default = Some(id.to_string());
|
|
353
|
+
}
|
|
354
|
+
records.push(ModelRecord {
|
|
355
|
+
provider: "grok".into(),
|
|
356
|
+
vendor: "xai".into(),
|
|
357
|
+
id: id.into(),
|
|
358
|
+
display_name: id.into(),
|
|
359
|
+
default: None,
|
|
360
|
+
aliases: Vec::new(),
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
if !has_models_header {
|
|
364
|
+
return Err(CatalogError::Malformed);
|
|
365
|
+
}
|
|
366
|
+
if records.is_empty() {
|
|
367
|
+
return Err(CatalogError::Empty);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
let mut default_id: Option<&str> = None;
|
|
371
|
+
for candidate in [
|
|
372
|
+
declared_default.as_deref(),
|
|
373
|
+
starred_default.as_deref(),
|
|
374
|
+
marked_default.as_deref(),
|
|
375
|
+
]
|
|
376
|
+
.into_iter()
|
|
377
|
+
.flatten()
|
|
378
|
+
{
|
|
379
|
+
if default_id.is_some_and(|current| current != candidate) {
|
|
380
|
+
return Err(CatalogError::Malformed);
|
|
381
|
+
}
|
|
382
|
+
default_id = Some(candidate);
|
|
383
|
+
}
|
|
384
|
+
if let Some(default_id) = default_id {
|
|
385
|
+
if !seen.contains(default_id) {
|
|
386
|
+
return Err(CatalogError::Malformed);
|
|
387
|
+
}
|
|
388
|
+
for record in &mut records {
|
|
389
|
+
record.default = Some(record.id.as_str() == default_id);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
Ok(records)
|
|
393
|
+
}
|
|
394
|
+
|
|
263
395
|
fn parse_codex_catalog(bytes: &[u8], provider: &str) -> Result<Vec<ModelRecord>, CatalogError> {
|
|
264
396
|
let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|_| CatalogError::Malformed)?;
|
|
265
397
|
let rows = value.get("models").and_then(serde_json::Value::as_array).ok_or(CatalogError::UnsupportedSchema)?;
|
|
@@ -457,15 +589,18 @@ pub(crate) fn run_command(
|
|
|
457
589
|
Some((writer, rx))
|
|
458
590
|
} else { None };
|
|
459
591
|
let mut output = None;
|
|
592
|
+
let mut pending_output = None;
|
|
593
|
+
let mut output_ready_at: Option<Instant> = None;
|
|
460
594
|
let mut too_large_at = None;
|
|
461
595
|
let status = loop {
|
|
462
|
-
match reader_rx.try_recv() {
|
|
596
|
+
match pending_output.take().map_or_else(|| reader_rx.try_recv(), Ok) {
|
|
463
597
|
Ok(Ok(bytes)) => {
|
|
464
598
|
if bytes.len() as u64 > max_bytes {
|
|
465
599
|
too_large_at.get_or_insert_with(Instant::now);
|
|
466
600
|
} else {
|
|
467
601
|
output = Some(bytes);
|
|
468
602
|
}
|
|
603
|
+
output_ready_at = Some(Instant::now());
|
|
469
604
|
}
|
|
470
605
|
Ok(Err(_)) => { terminate(&mut child, pid); return Err(CatalogError::OutputReadFailed); }
|
|
471
606
|
Err(mpsc::TryRecvError::Disconnected) if output.is_none() && too_large_at.is_none() => { terminate(&mut child, pid); return Err(CatalogError::OutputReadFailed); }
|
|
@@ -482,7 +617,24 @@ pub(crate) fn run_command(
|
|
|
482
617
|
terminate(&mut child, pid);
|
|
483
618
|
return Err(CatalogError::OutputTooLarge);
|
|
484
619
|
}
|
|
485
|
-
Ok(None) if started.elapsed() < timeout =>
|
|
620
|
+
Ok(None) if started.elapsed() < timeout => {
|
|
621
|
+
let mut wait = timeout.saturating_sub(started.elapsed()).min(Duration::from_millis(10));
|
|
622
|
+
if let Some(over_at) = too_large_at {
|
|
623
|
+
wait = wait.min(Duration::from_millis(100).saturating_sub(over_at.elapsed()));
|
|
624
|
+
}
|
|
625
|
+
if output.is_none() && too_large_at.is_none() {
|
|
626
|
+
// Reader completion wakes us instead of trailing a blind 10ms sleep.
|
|
627
|
+
if let Ok(result) = reader_rx.recv_timeout(wait) {
|
|
628
|
+
pending_output = Some(result);
|
|
629
|
+
}
|
|
630
|
+
} else {
|
|
631
|
+
// stdout can close before exit; never busy-poll a disconnected channel.
|
|
632
|
+
let interval = output_ready_at.map_or(Duration::from_millis(10), |at| {
|
|
633
|
+
catalog_exit_poll_interval(at.elapsed())
|
|
634
|
+
});
|
|
635
|
+
std::thread::sleep(wait.min(interval));
|
|
636
|
+
}
|
|
637
|
+
}
|
|
486
638
|
Ok(None) if too_large_at.is_some() => { terminate(&mut child, pid); return Err(CatalogError::OutputTooLarge); }
|
|
487
639
|
Ok(None) => { terminate(&mut child, pid); return Err(CatalogError::Timeout); }
|
|
488
640
|
Err(_) => { terminate(&mut child, pid); return Err(CatalogError::CommandCouldNotBeObserved); }
|
|
@@ -510,6 +662,16 @@ pub(crate) fn run_command(
|
|
|
510
662
|
output.ok_or(CatalogError::OutputReadFailed)
|
|
511
663
|
}
|
|
512
664
|
|
|
665
|
+
fn catalog_exit_poll_interval(elapsed: Duration) -> Duration {
|
|
666
|
+
[1, 3, 6, 10]
|
|
667
|
+
.into_iter()
|
|
668
|
+
.map(Duration::from_millis)
|
|
669
|
+
.find(|checkpoint| *checkpoint > elapsed)
|
|
670
|
+
.map_or(Duration::from_millis(10), |checkpoint| {
|
|
671
|
+
checkpoint.saturating_sub(elapsed)
|
|
672
|
+
})
|
|
673
|
+
}
|
|
674
|
+
|
|
513
675
|
fn terminate(child: &mut Child, pid: u32) {
|
|
514
676
|
#[cfg(unix)]
|
|
515
677
|
unsafe {
|
|
@@ -568,6 +730,151 @@ impl Drop for OwnedTempDir {
|
|
|
568
730
|
fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); }
|
|
569
731
|
}
|
|
570
732
|
|
|
733
|
+
#[cfg(all(test, unix))]
|
|
734
|
+
mod command_wait_regressions {
|
|
735
|
+
use super::*;
|
|
736
|
+
|
|
737
|
+
#[derive(Default)]
|
|
738
|
+
struct Observer {
|
|
739
|
+
pid: Option<u32>,
|
|
740
|
+
exits: Vec<Option<i32>>,
|
|
741
|
+
output_timeouts: usize,
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
impl CatalogObserver for Observer {
|
|
745
|
+
fn spawned(&mut self, pid: u32) { self.pid = Some(pid); }
|
|
746
|
+
fn exited(&mut self, status: &ExitStatus) { self.exits.push(status.code()); }
|
|
747
|
+
fn output_timeout(&mut self) { self.output_timeouts += 1; }
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
fn run(
|
|
751
|
+
script: &str,
|
|
752
|
+
input: Option<&[u8]>,
|
|
753
|
+
timeout: Duration,
|
|
754
|
+
max_bytes: u64,
|
|
755
|
+
observer: &mut Observer,
|
|
756
|
+
) -> Result<Vec<u8>, CatalogError> {
|
|
757
|
+
run_command(
|
|
758
|
+
Path::new("/bin/sh"), &["-c", script], input, None, &[], &[],
|
|
759
|
+
timeout, max_bytes, observer,
|
|
760
|
+
)
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
fn assert_owned_child_gone(observer: &Observer) {
|
|
764
|
+
let pid = observer.pid.unwrap();
|
|
765
|
+
assert!(pid > 1);
|
|
766
|
+
let status = Command::new("/bin/kill")
|
|
767
|
+
.args(["-0", &pid.to_string()])
|
|
768
|
+
.stdout(Stdio::null())
|
|
769
|
+
.stderr(Stdio::null())
|
|
770
|
+
.status()
|
|
771
|
+
.unwrap();
|
|
772
|
+
assert!(!status.success(), "owned child must be reaped");
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
#[test]
|
|
776
|
+
fn eof_and_success_preserve_input_and_raw_output() {
|
|
777
|
+
let mut observer = Observer::default();
|
|
778
|
+
let input = b"catalog\0\xff\n";
|
|
779
|
+
let output = run(
|
|
780
|
+
"exec /bin/cat", Some(input), CATALOG_TIMEOUT, MAX_CATALOG_BYTES, &mut observer,
|
|
781
|
+
).unwrap();
|
|
782
|
+
assert_eq!(output, input.to_vec());
|
|
783
|
+
assert_eq!(observer.exits, vec![Some(0)]);
|
|
784
|
+
assert_eq!(observer.output_timeouts, 0);
|
|
785
|
+
assert_owned_child_gone(&observer);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
#[test]
|
|
789
|
+
fn stdout_eof_does_not_hide_a_later_nonzero_exit() {
|
|
790
|
+
let mut observer = Observer::default();
|
|
791
|
+
let error = run(
|
|
792
|
+
"printf 'partial'; exec 1>&-; /bin/sleep 0.03; exit 17",
|
|
793
|
+
None, CATALOG_TIMEOUT, MAX_CATALOG_BYTES, &mut observer,
|
|
794
|
+
).unwrap_err();
|
|
795
|
+
assert_eq!(error, CatalogError::CommandFailed);
|
|
796
|
+
assert_eq!(observer.exits, vec![Some(17)]);
|
|
797
|
+
assert_eq!(observer.output_timeouts, 0);
|
|
798
|
+
assert_owned_child_gone(&observer);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
#[test]
|
|
802
|
+
fn stdout_eof_does_not_disable_the_ten_second_process_timeout() {
|
|
803
|
+
let mut observer = Observer::default();
|
|
804
|
+
let started = Instant::now();
|
|
805
|
+
let error = run(
|
|
806
|
+
"exec 1>&-; exec /bin/sleep 30", None, CATALOG_TIMEOUT,
|
|
807
|
+
MAX_CATALOG_BYTES, &mut observer,
|
|
808
|
+
).unwrap_err();
|
|
809
|
+
let elapsed = started.elapsed();
|
|
810
|
+
assert_eq!(CATALOG_TIMEOUT, Duration::from_secs(10));
|
|
811
|
+
assert_eq!(error, CatalogError::Timeout);
|
|
812
|
+
assert!(elapsed >= CATALOG_TIMEOUT);
|
|
813
|
+
assert!(elapsed < CATALOG_TIMEOUT + Duration::from_secs(2));
|
|
814
|
+
assert!(observer.exits.is_empty());
|
|
815
|
+
assert_eq!(observer.output_timeouts, 0);
|
|
816
|
+
assert_owned_child_gone(&observer);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
#[test]
|
|
820
|
+
fn exact_one_mib_is_allowed_and_one_more_byte_is_rejected() {
|
|
821
|
+
assert_eq!(MAX_CATALOG_BYTES, 1024 * 1024);
|
|
822
|
+
let mut observer = Observer::default();
|
|
823
|
+
let script = format!("exec /usr/bin/head -c {MAX_CATALOG_BYTES} /dev/zero");
|
|
824
|
+
let output = run(
|
|
825
|
+
&script, None, CATALOG_TIMEOUT, MAX_CATALOG_BYTES, &mut observer,
|
|
826
|
+
).unwrap();
|
|
827
|
+
assert_eq!(output.len() as u64, MAX_CATALOG_BYTES);
|
|
828
|
+
assert_owned_child_gone(&observer);
|
|
829
|
+
let mut observer = Observer::default();
|
|
830
|
+
let script = format!("exec /usr/bin/head -c {} /dev/zero", MAX_CATALOG_BYTES + 1);
|
|
831
|
+
assert_eq!(
|
|
832
|
+
run(&script, None, CATALOG_TIMEOUT, MAX_CATALOG_BYTES, &mut observer),
|
|
833
|
+
Err(CatalogError::OutputTooLarge)
|
|
834
|
+
);
|
|
835
|
+
assert_owned_child_gone(&observer);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
#[test]
|
|
839
|
+
fn oversized_output_keeps_one_hundred_millisecond_exit_grace() {
|
|
840
|
+
let mut observer = Observer::default();
|
|
841
|
+
let started = Instant::now();
|
|
842
|
+
let error = run(
|
|
843
|
+
"printf '%65s' x; exec /bin/sleep 30", None, CATALOG_TIMEOUT, 64, &mut observer,
|
|
844
|
+
).unwrap_err();
|
|
845
|
+
assert_eq!(error, CatalogError::OutputTooLarge);
|
|
846
|
+
assert!(started.elapsed() >= Duration::from_millis(100));
|
|
847
|
+
assert!(started.elapsed() < Duration::from_secs(2));
|
|
848
|
+
assert_owned_child_gone(&observer);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
#[test]
|
|
852
|
+
fn exited_child_with_inherited_stdout_keeps_output_timeout_observation() {
|
|
853
|
+
let mut observer = Observer::default();
|
|
854
|
+
let error = run(
|
|
855
|
+
"/bin/sleep 30 & exit 0", None, Duration::from_millis(200),
|
|
856
|
+
MAX_CATALOG_BYTES, &mut observer,
|
|
857
|
+
).unwrap_err();
|
|
858
|
+
assert_eq!(error, CatalogError::Timeout);
|
|
859
|
+
assert_eq!(observer.exits, vec![Some(0)]);
|
|
860
|
+
assert_eq!(observer.output_timeouts, 1);
|
|
861
|
+
assert_owned_child_gone(&observer);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
#[test]
|
|
865
|
+
fn early_eof_polling_keeps_ten_millisecond_anchor_and_then_old_cadence() {
|
|
866
|
+
let mut elapsed = Duration::ZERO;
|
|
867
|
+
for (sleep_ms, check_ms) in [(1, 1), (2, 3), (3, 6), (4, 10), (10, 20)] {
|
|
868
|
+
let interval = catalog_exit_poll_interval(elapsed);
|
|
869
|
+
assert_eq!(interval, Duration::from_millis(sleep_ms));
|
|
870
|
+
elapsed += interval;
|
|
871
|
+
assert_eq!(elapsed, Duration::from_millis(check_ms));
|
|
872
|
+
}
|
|
873
|
+
assert_eq!(catalog_exit_poll_interval(Duration::from_micros(9500)), Duration::from_micros(500));
|
|
874
|
+
assert_eq!(catalog_exit_poll_interval(Duration::from_millis(50)), Duration::from_millis(10));
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
571
878
|
#[cfg(test)]
|
|
572
879
|
mod tests {
|
|
573
880
|
use super::*;
|
|
@@ -647,4 +954,35 @@ mod tests {
|
|
|
647
954
|
assert!(!model_matches(&row, "LUNA absent"));
|
|
648
955
|
assert!(model_matches(&row, " "));
|
|
649
956
|
}
|
|
957
|
+
|
|
958
|
+
#[test]
|
|
959
|
+
fn frozen_cursor_catalog_skips_only_its_native_trailing_tip() {
|
|
960
|
+
let frozen = include_bytes!("testdata/cursor-list-models-native.stdout");
|
|
961
|
+
let records = parse_cursor_catalog(frozen).unwrap();
|
|
962
|
+
let low = records.iter().find(|row| row.id == "gpt-5.6-luna-low").unwrap();
|
|
963
|
+
assert_eq!(low.provider.as_str(), "cursor_agent");
|
|
964
|
+
assert_eq!(low.vendor.as_str(), "gpt");
|
|
965
|
+
assert_eq!(low.display_name, "GPT-5.6 Luna 1M Low (current)");
|
|
966
|
+
assert_eq!(low.default, Some(false));
|
|
967
|
+
assert!(low.aliases.is_empty());
|
|
968
|
+
let xhigh = records.iter().find(|row| row.id == "gpt-5.6-luna-xhigh").unwrap();
|
|
969
|
+
assert_eq!(xhigh.id.as_str(), "gpt-5.6-luna-xhigh");
|
|
970
|
+
assert_eq!(xhigh.display_name, "GPT-5.6 Luna 1M Extra High");
|
|
971
|
+
let auto = records.iter().find(|row| row.id == "auto").unwrap();
|
|
972
|
+
assert_eq!(auto.default, Some(true));
|
|
973
|
+
|
|
974
|
+
let text = std::str::from_utf8(frozen).unwrap();
|
|
975
|
+
let without_tip = text.split_once("\nTip: use --model ").unwrap().0;
|
|
976
|
+
assert_eq!(parse_cursor_catalog(without_tip.as_bytes()).unwrap(), parse_cursor_catalog(frozen).unwrap());
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
#[test]
|
|
980
|
+
fn cursor_tip_compatibility_keeps_fail_closed_row_validation() {
|
|
981
|
+
let duplicate = b"Available models\na - A\na - A\n";
|
|
982
|
+
assert!(matches!(parse_cursor_catalog(duplicate), Err(CatalogError::DuplicateIdentity(_))));
|
|
983
|
+
assert!(matches!(parse_cursor_catalog(b"Available models\n"), Err(CatalogError::Empty)));
|
|
984
|
+
assert!(matches!(parse_cursor_catalog(&[b'A', 0xff]), Err(CatalogError::InvalidUtf8)));
|
|
985
|
+
assert!(matches!(parse_cursor_catalog(b"Available models\na - A\nnot a model row\n"), Err(CatalogError::Malformed)));
|
|
986
|
+
assert!(matches!(parse_cursor_catalog(b"Available models\na - A\nTip: use --model a to switch.\nunexpected tail\n"), Err(CatalogError::Malformed)));
|
|
987
|
+
}
|
|
650
988
|
}
|