mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 08:01:06 +00:00
1124 lines
36 KiB
Rust
1124 lines
36 KiB
Rust
use super::*;
|
||
|
||
fn remote_manifest(version: &str, state: &str, contains: &str) -> String {
|
||
format!(
|
||
r#"
|
||
id = "codex"
|
||
version = "{version}"
|
||
min_engine_version = 1
|
||
updated_at = "2026-06-10T12:00:00Z"
|
||
|
||
[[rules]]
|
||
id = "test"
|
||
state = "{state}"
|
||
contains = ["{contains}"]
|
||
"#
|
||
)
|
||
}
|
||
|
||
fn local_manifest(state: &str, contains: &str) -> String {
|
||
format!(
|
||
r#"
|
||
id = "codex"
|
||
|
||
[[rules]]
|
||
id = "test"
|
||
state = "{state}"
|
||
contains = ["{contains}"]
|
||
"#
|
||
)
|
||
}
|
||
|
||
fn rules_manifest(rules: &str) -> String {
|
||
format!(
|
||
r#"
|
||
id = "codex"
|
||
|
||
{rules}
|
||
"#
|
||
)
|
||
}
|
||
|
||
fn with_manifest_dirs<T>(name: &str, f: impl FnOnce() -> T) -> T {
|
||
let _guard = crate::config::test_config_env_lock().lock().unwrap();
|
||
let old_config = std::env::var_os("XDG_CONFIG_HOME");
|
||
let old_state = std::env::var_os("XDG_STATE_HOME");
|
||
let base = std::env::temp_dir().join(format!(
|
||
"herdr-manifest-loader-{name}-{}",
|
||
std::process::id()
|
||
));
|
||
let config_dir = base.join("config");
|
||
let state_dir = base.join("state");
|
||
let _ = std::fs::remove_dir_all(&base);
|
||
std::env::set_var("XDG_CONFIG_HOME", &config_dir);
|
||
std::env::set_var("XDG_STATE_HOME", &state_dir);
|
||
reload_manifests();
|
||
let result = f();
|
||
match old_config {
|
||
Some(value) => std::env::set_var("XDG_CONFIG_HOME", value),
|
||
None => std::env::remove_var("XDG_CONFIG_HOME"),
|
||
}
|
||
match old_state {
|
||
Some(value) => std::env::set_var("XDG_STATE_HOME", value),
|
||
None => std::env::remove_var("XDG_STATE_HOME"),
|
||
}
|
||
reload_manifests();
|
||
let _ = std::fs::remove_dir_all(&base);
|
||
result
|
||
}
|
||
|
||
fn write_remote_codex(content: &str) {
|
||
let path = crate::detect::manifest_update::remote_manifest_path(Agent::Codex);
|
||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||
std::fs::write(path, content).unwrap();
|
||
reload_manifests();
|
||
}
|
||
|
||
fn write_remote_codex_without_reload(content: &str) {
|
||
let path = crate::detect::manifest_update::remote_manifest_path(Agent::Codex);
|
||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||
std::fs::write(path, content).unwrap();
|
||
}
|
||
|
||
fn write_local_codex(content: &str) {
|
||
let path = override_path(Agent::Codex).unwrap();
|
||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||
std::fs::write(path, content).unwrap();
|
||
reload_manifests();
|
||
}
|
||
|
||
#[test]
|
||
fn known_agent_no_match_defaults_to_idle_fallback() {
|
||
let explain = explain(Agent::Codex, "ordinary prompt text");
|
||
|
||
assert_eq!(explain.state, AgentState::Idle);
|
||
assert!(!explain.visible_idle);
|
||
assert_eq!(
|
||
explain.fallback_reason.as_deref(),
|
||
Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn rule_semantics_apply_gates_priority_and_line_regex() {
|
||
with_manifest_dirs("rule-semantics", || {
|
||
write_local_codex(&rules_manifest(
|
||
r#"
|
||
[[rules]]
|
||
id = "low_contains"
|
||
state = "idle"
|
||
priority = 1
|
||
contains = ["match"]
|
||
|
||
[[rules]]
|
||
id = "high_nested_gates"
|
||
state = "working"
|
||
priority = 10
|
||
contains = ["match"]
|
||
all = [
|
||
{ any = [{ regex = ["w[io]n"] }, { contains = ["fallback"] }] },
|
||
]
|
||
not = [
|
||
{ contains = ["blocked"] },
|
||
]
|
||
|
||
[[rules]]
|
||
id = "line_regex"
|
||
state = "blocked"
|
||
priority = 20
|
||
line_regex = ["^exact line$"]
|
||
"#,
|
||
));
|
||
|
||
let high = explain(Agent::Codex, "match win");
|
||
assert_eq!(high.state, AgentState::Working);
|
||
assert_eq!(
|
||
high.matched_rule.as_ref().map(|rule| rule.id.as_str()),
|
||
Some("high_nested_gates")
|
||
);
|
||
|
||
let not_gate = explain(Agent::Codex, "match win blocked");
|
||
assert_eq!(not_gate.state, AgentState::Idle);
|
||
assert_eq!(
|
||
not_gate.matched_rule.as_ref().map(|rule| rule.id.as_str()),
|
||
Some("low_contains")
|
||
);
|
||
|
||
let line = explain(Agent::Codex, "before\nexact line\nafter");
|
||
assert_eq!(line.state, AgentState::Blocked);
|
||
assert_eq!(
|
||
line.matched_rule.as_ref().map(|rule| rule.id.as_str()),
|
||
Some("line_regex")
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn remote_manifest_loads_between_local_override_and_bundled() {
|
||
with_manifest_dirs("remote-source", || {
|
||
write_remote_codex(&remote_manifest("9999.01.01.1", "blocked", "remote-ready"));
|
||
|
||
let explain = explain(Agent::Codex, "remote-ready");
|
||
|
||
assert_eq!(explain.state, AgentState::Blocked);
|
||
assert!(matches!(
|
||
explain.source,
|
||
Some(ManifestSource::Remote { .. })
|
||
));
|
||
assert_eq!(explain.manifest_version.as_deref(), Some("9999.01.01.1"));
|
||
assert_eq!(
|
||
explain.cached_remote_version.as_deref(),
|
||
Some("9999.01.01.1")
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn fallback_explain_preserves_active_manifest_version() {
|
||
with_manifest_dirs("fallback-version", || {
|
||
write_remote_codex(&remote_manifest("9999.01.01.1", "blocked", "remote-ready"));
|
||
|
||
let explain = explain(Agent::Codex, "ordinary prompt text");
|
||
|
||
assert_eq!(explain.state, AgentState::Idle);
|
||
assert_eq!(
|
||
explain.fallback_reason.as_deref(),
|
||
Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK)
|
||
);
|
||
assert_eq!(explain.manifest_version.as_deref(), Some("9999.01.01.1"));
|
||
assert!(matches!(
|
||
explain.source,
|
||
Some(ManifestSource::Remote { .. })
|
||
));
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn older_cached_remote_manifest_does_not_shadow_newer_bundled_manifest() {
|
||
with_manifest_dirs("older-remote-bundled-fallback", || {
|
||
write_remote_codex(&remote_manifest("2026.06.10.0", "blocked", "remote-ready"));
|
||
|
||
let explain = explain(Agent::Codex, "remote-ready");
|
||
|
||
assert_eq!(explain.state, AgentState::Idle);
|
||
assert!(matches!(explain.source, Some(ManifestSource::Bundled)));
|
||
assert_eq!(
|
||
explain.cached_remote_version.as_deref(),
|
||
Some("2026.06.10.0")
|
||
);
|
||
assert!(explain
|
||
.warning
|
||
.as_deref()
|
||
.is_some_and(|warning| warning.contains("older than bundled")));
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn local_override_shadows_cached_remote_manifest() {
|
||
with_manifest_dirs("local-shadows-remote", || {
|
||
write_remote_codex(&remote_manifest("9999.01.01.1", "blocked", "remote-ready"));
|
||
write_local_codex(&local_manifest("idle", "local-ready"));
|
||
|
||
let explain = explain(Agent::Codex, "local-ready");
|
||
|
||
assert_eq!(explain.state, AgentState::Idle);
|
||
assert!(matches!(explain.source, Some(ManifestSource::Override(_))));
|
||
assert!(explain.local_override_shadowing_remote);
|
||
assert_eq!(
|
||
explain.cached_remote_version.as_deref(),
|
||
Some("9999.01.01.1")
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn invalid_local_override_falls_back_to_cached_remote_manifest() {
|
||
with_manifest_dirs("invalid-local-remote-fallback", || {
|
||
write_remote_codex(&remote_manifest("9999.01.01.1", "blocked", "remote-ready"));
|
||
write_local_codex("id = ");
|
||
|
||
let explain = explain(Agent::Codex, "remote-ready");
|
||
|
||
assert_eq!(explain.state, AgentState::Blocked);
|
||
assert!(matches!(
|
||
explain.source,
|
||
Some(ManifestSource::Remote { .. })
|
||
));
|
||
assert!(explain.warning.is_some());
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn detection_uses_cached_manifest_until_explicit_reload() {
|
||
with_manifest_dirs("cache-boundary", || {
|
||
write_remote_codex(&remote_manifest("9999.01.01.1", "blocked", "cached-ready"));
|
||
|
||
let cached = explain(Agent::Codex, "cached-ready");
|
||
assert_eq!(cached.state, AgentState::Blocked);
|
||
assert!(matches!(cached.source, Some(ManifestSource::Remote { .. })));
|
||
assert_eq!(
|
||
cached.matched_rule.as_ref().map(|rule| rule.id.as_str()),
|
||
Some("test")
|
||
);
|
||
|
||
write_remote_codex_without_reload(&remote_manifest("9999.01.01.2", "working", "new-ready"));
|
||
|
||
let unchanged = explain(Agent::Codex, "new-ready");
|
||
assert_eq!(unchanged.state, AgentState::Idle);
|
||
assert_eq!(
|
||
unchanged.fallback_reason.as_deref(),
|
||
Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK)
|
||
);
|
||
assert_eq!(
|
||
unchanged.cached_remote_version.as_deref(),
|
||
Some("9999.01.01.1")
|
||
);
|
||
|
||
reload_manifests();
|
||
|
||
let reloaded = explain(Agent::Codex, "new-ready");
|
||
assert_eq!(reloaded.state, AgentState::Working);
|
||
assert_eq!(
|
||
reloaded.cached_remote_version.as_deref(),
|
||
Some("9999.01.01.2")
|
||
);
|
||
assert_eq!(
|
||
reloaded.matched_rule.as_ref().map(|rule| rule.id.as_str()),
|
||
Some("test")
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn all_bundled_manifests_parse_and_validate() {
|
||
for agent in Agent::SCREEN_MANIFEST_AGENTS {
|
||
assert!(
|
||
bundled_manifest(agent).is_some(),
|
||
"missing bundled manifest for {}",
|
||
agent_label(agent)
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn devin_manifest_detects_idle_working_and_blocked_states() {
|
||
let idle = explain(
|
||
Agent::Devin,
|
||
"─────────────────────────────────────────────────────\n❭ Ask Devin to build features, fix bugs, or work on\n your code\n─────────────────────────────────────────────────────\nSWE-1.6 Context: 16k / 200k tokens (7%)",
|
||
);
|
||
assert_eq!(idle.state, AgentState::Idle);
|
||
assert!(idle.visible_idle);
|
||
|
||
let live_footer_idle = explain(
|
||
Agent::Devin,
|
||
"Done.\n\n────────────────────────────────────────────────── (bypass permissions on) ─\n❭\n────────────────────────────────────────────────────────────────────────────\nClaude Opus 4.6 Thinking Context: 38k / 200k tokens (18%)",
|
||
);
|
||
assert_eq!(live_footer_idle.state, AgentState::Idle);
|
||
assert_eq!(
|
||
live_footer_idle
|
||
.matched_rule
|
||
.as_ref()
|
||
.map(|rule| rule.id.as_str()),
|
||
Some("live_prompt_footer")
|
||
);
|
||
assert!(live_footer_idle.visible_idle);
|
||
|
||
let welcome_footer_idle = explain(
|
||
Agent::Devin,
|
||
"⠀⠀⠀⠀⠀⣴⣾⣶⡄⠀⠀⠀⠀\n⠀⣴⣾⣶⡾⠛⠿⠟⠃⣴⣾⣶⡄ Devin CLI\n⠀⠛⠿⠟⠃⣴⣾⣶⡾⠛⠿⠟⠃ v2026.5.26-8\n⠀⣤⣶⣦⡄⠻⢿⠿⢷⣤⣶⣦⡄\n⠀⠻⢿⠿⢷⣤⣶⣦⡄⠻⢿⠿⠃ Hybrid\n⠀⠀⠀⠀⠀⠻⢿⠿⠃⠀⠀⠀⠀\n\n───────────────────────────\n❭ Ask Devin to build\n features, fix bugs, or\n work on your code\n───────────────────────────\nClaude Opus Looking for\n4.6 Thinkingplan mode? /\n plan",
|
||
);
|
||
assert_eq!(welcome_footer_idle.state, AgentState::Idle);
|
||
assert_eq!(
|
||
welcome_footer_idle
|
||
.matched_rule
|
||
.as_ref()
|
||
.map(|rule| rule.id.as_str()),
|
||
Some("welcome_prompt_footer")
|
||
);
|
||
assert!(welcome_footer_idle.visible_idle);
|
||
|
||
let working = explain(
|
||
Agent::Devin,
|
||
"◔ Reading shell 91b655\n │ Timeout: 35s\n\n⠀⡆ Running tools · 27s (esc to interrupt)\n─────────────────────────────────────────────────────\n❭ Guide Devin while it works",
|
||
);
|
||
assert_eq!(working.state, AgentState::Working);
|
||
assert!(working.visible_working);
|
||
|
||
let trust_prompt = explain(
|
||
Agent::Devin,
|
||
"Do you trust the authors of this directory?\nFor security, devin should not be run in directories\nwith untrusted content.\n❭ 1 Yes, trust /private/tmp/devin-hook-probe\n· 2 No, exit",
|
||
);
|
||
assert_eq!(trust_prompt.state, AgentState::Blocked);
|
||
assert!(trust_prompt.visible_blocker);
|
||
|
||
let permission_prompt = explain(
|
||
Agent::Devin,
|
||
"⏺ Running command\n └ $ sleep 30\n\n❭ 1 Yes (Approve once)\n· 2 Yes, allow `sleep` commands\n· 3 Yes, always allow `sleep` commands\n· 4 No\n↑↓ select · ↵ confirm · esc cancel",
|
||
);
|
||
assert_eq!(permission_prompt.state, AgentState::Blocked);
|
||
assert!(permission_prompt.visible_blocker);
|
||
}
|
||
|
||
#[test]
|
||
fn muse_manifest_requires_complete_live_controls() {
|
||
let working = explain(
|
||
Agent::Muse,
|
||
"⟩ hello\n\n◆ Working (0s · esc to interrupt)\n\n────────────────\n⟩\n────────────────\ngpt-5.4 · minimal · /workspace",
|
||
);
|
||
assert_eq!(working.state, AgentState::Working);
|
||
assert!(working.visible_working);
|
||
|
||
let picker = explain(
|
||
Agent::Muse,
|
||
"Which option should I use?\n\n› 1. Alpha\n 2. Beta\n\nEnter to select · ↑/↓ to move · Tab for an optional note · Esc to interrupt\n\n────────────────\n⟩\n────────────────\ngpt-5.4 · minimal · /workspace",
|
||
);
|
||
assert_eq!(picker.state, AgentState::Blocked);
|
||
assert!(picker.visible_blocker);
|
||
|
||
let command_approval = explain(
|
||
Agent::Muse,
|
||
"Would you like to run the following command?\n\n$ printf muse-safe-probe\n\n› 1. Allow this stage once (y)\n 2. Always allow in this workspace: printf muse-safe-probe ... (p)\n 3. Abort the entire command (esc)\n────────────────\ngpt-5.4 · minimal · /workspace",
|
||
);
|
||
assert_eq!(command_approval.state, AgentState::Blocked);
|
||
assert!(command_approval.visible_blocker);
|
||
|
||
let network_approval = explain(
|
||
Agent::Muse,
|
||
"network: example.com:443 https\nrequested by:\n$ curl -fsS https://example.com\n\n› 1. Yes, proceed (y)\n 2. Yes, don't ask again this session (p) example.com:443 (https)\n 3. No, and tell Muse Code what to do differently (esc)\n────────────────\ngpt-5.4 · minimal · /workspace",
|
||
);
|
||
assert_eq!(network_approval.state, AgentState::Blocked);
|
||
assert!(network_approval.visible_blocker);
|
||
|
||
let menu = explain(
|
||
Agent::Muse,
|
||
"Theme\n\n⟩ Default (active)\n Dynamic\n\n↑↓ move · enter save · esc go back",
|
||
);
|
||
assert_eq!(menu.state, AgentState::Unknown);
|
||
assert!(menu.skip_state_update);
|
||
assert!(!menu.visible_blocker);
|
||
|
||
let ordinary_reply = explain(
|
||
Agent::Muse,
|
||
"⟩ say the phrase\n\n◆ Yes, proceed\n\n────────────────\n⟩\n────────────────\ngpt-5.4 · minimal · /workspace",
|
||
);
|
||
assert_eq!(ordinary_reply.state, AgentState::Idle);
|
||
assert!(ordinary_reply.visible_idle);
|
||
}
|
||
|
||
#[test]
|
||
fn manifest_validation_rejects_unknown_fields_empty_rules_invalid_regions_and_regexes() {
|
||
assert!(parse_manifest(
|
||
r#"
|
||
id = "codex"
|
||
|
||
[[rules]]
|
||
id = "typo"
|
||
state = "working"
|
||
contain = ["Working"]
|
||
"#
|
||
)
|
||
.is_err());
|
||
|
||
assert!(parse_manifest(
|
||
r#"
|
||
id = "codex"
|
||
|
||
[[rules]]
|
||
id = "empty"
|
||
state = "working"
|
||
"#
|
||
)
|
||
.is_err());
|
||
|
||
assert!(parse_manifest(
|
||
r#"
|
||
id = "codex"
|
||
|
||
[[rules]]
|
||
id = "bad_region"
|
||
state = "working"
|
||
region = "after_last_promt_marker"
|
||
contains = ["Working"]
|
||
"#
|
||
)
|
||
.is_err());
|
||
|
||
assert!(parse_manifest(
|
||
r#"
|
||
id = "codex"
|
||
|
||
[[rules]]
|
||
id = "bad_regex"
|
||
state = "working"
|
||
regex = ["["]
|
||
"#
|
||
)
|
||
.is_err());
|
||
|
||
assert!(parse_manifest(
|
||
r#"
|
||
id = "codex"
|
||
|
||
[[rules]]
|
||
id = "bad_nested_regex"
|
||
state = "working"
|
||
any = [{ line_regex = ["["] }]
|
||
"#
|
||
)
|
||
.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn manifest_validation_keeps_skip_rules_neutral() {
|
||
assert!(parse_manifest(
|
||
r#"
|
||
id = "codex"
|
||
|
||
[[rules]]
|
||
id = "bad_skip_state"
|
||
state = "idle"
|
||
skip_state_update = true
|
||
contains = ["menu"]
|
||
"#
|
||
)
|
||
.is_err());
|
||
|
||
assert!(parse_manifest(
|
||
r#"
|
||
id = "codex"
|
||
|
||
[[rules]]
|
||
id = "bad_skip_visible"
|
||
state = "unknown"
|
||
skip_state_update = true
|
||
visible_blocker = true
|
||
contains = ["menu"]
|
||
"#
|
||
)
|
||
.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn manifest_validation_rejects_excessive_rule_count() {
|
||
let mut manifest = String::from(
|
||
r#"
|
||
id = "codex"
|
||
"#,
|
||
);
|
||
for index in 0..129 {
|
||
manifest.push_str(&format!(
|
||
r#"
|
||
[[rules]]
|
||
id = "rule_{index}"
|
||
state = "idle"
|
||
contains = ["ready"]
|
||
"#
|
||
));
|
||
}
|
||
|
||
assert!(parse_manifest(&manifest).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn manifest_validation_rejects_excessive_gate_depth() {
|
||
let manifest = r#"
|
||
id = "codex"
|
||
|
||
[[rules]]
|
||
id = "deep"
|
||
state = "idle"
|
||
contains = ["ready"]
|
||
all = [
|
||
{ contains = ["1"], all = [
|
||
{ contains = ["2"], all = [
|
||
{ contains = ["3"], all = [
|
||
{ contains = ["4"], all = [
|
||
{ contains = ["5"], all = [
|
||
{ contains = ["6"], all = [
|
||
{ contains = ["7"], all = [
|
||
{ contains = ["8"], all = [
|
||
{ contains = ["9"] },
|
||
] },
|
||
] },
|
||
] },
|
||
] },
|
||
] },
|
||
] },
|
||
] },
|
||
] },
|
||
]
|
||
"#;
|
||
|
||
assert!(parse_manifest(manifest).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn manifest_validation_rejects_excessive_matchers() {
|
||
let matchers = (0..33)
|
||
.map(|index| format!(r#""m{index}""#))
|
||
.collect::<Vec<_>>()
|
||
.join(", ");
|
||
let manifest = format!(
|
||
r#"
|
||
id = "codex"
|
||
|
||
[[rules]]
|
||
id = "many"
|
||
state = "idle"
|
||
contains = [{matchers}]
|
||
"#
|
||
);
|
||
|
||
assert!(parse_manifest(&manifest).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn bottom_non_empty_lines_uses_bottom_occurrence_for_repeated_text() {
|
||
let content = "marker\nold\n\nmiddle\nmarker\nnew\n";
|
||
|
||
assert_eq!(
|
||
region(
|
||
DetectionInput {
|
||
screen: content,
|
||
osc_title: "",
|
||
osc_progress: "",
|
||
},
|
||
"bottom_non_empty_lines(2)"
|
||
),
|
||
"marker\nnew\n"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn top_non_empty_lines_uses_top_occurrence_for_repeated_text() {
|
||
let content = "\nmarker\nold\n\nmiddle\nmarker\nnew\n";
|
||
|
||
assert_eq!(
|
||
region(
|
||
DetectionInput {
|
||
screen: content,
|
||
osc_title: "",
|
||
osc_progress: "",
|
||
},
|
||
"top_non_empty_lines(2)"
|
||
),
|
||
"\nmarker\nold\n"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn top_non_empty_lines_requires_a_canonical_positive_bounded_count() {
|
||
let name = "top_non_empty_lines";
|
||
assert!(validate_region_name(&format!("{name}(1)")).is_ok());
|
||
assert!(validate_region_name(&format!("{name}({})", u16::MAX)).is_ok());
|
||
for count in ["0", "01", "+1", "65536", "999999999999999999999999"] {
|
||
assert!(
|
||
validate_region_name(&format!("{name}({count})")).is_err(),
|
||
"{name} accepted invalid count {count}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn top_non_empty_lines_requires_engine_three_when_declared() {
|
||
let manifest = r#"
|
||
id = "grok"
|
||
version = "1"
|
||
min_engine_version = 2
|
||
|
||
[[rules]]
|
||
id = "background"
|
||
state = "working"
|
||
region = " top_non_empty_lines(1) "
|
||
contains = ["active"]
|
||
"#;
|
||
|
||
assert!(parse_manifest(manifest).is_err());
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// OSC rule tests — exercise the new osc_title / osc_progress regions against
|
||
// the bundled Claude and Codex manifests.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn osc_explain(
|
||
agent: Agent,
|
||
screen: &str,
|
||
osc_title: &str,
|
||
osc_progress: &str,
|
||
) -> DetectionExplain {
|
||
explain_with_input(
|
||
agent,
|
||
DetectionInput {
|
||
screen,
|
||
osc_title,
|
||
osc_progress,
|
||
},
|
||
)
|
||
}
|
||
|
||
// --- Claude OSC rules ---
|
||
|
||
#[test]
|
||
fn claude_idle_prompt_with_background_shell_is_idle() {
|
||
// Captured from Claude Code 2.1.251 after its foreground turn ended while
|
||
// a long-lived background shell remained active (issue #3414).
|
||
let screen = concat!(
|
||
"✻ Sautéed for 10s · 1 shell still running\n\n",
|
||
"──────────────────────────────────────────────────────── WINDOWS ─\n",
|
||
"❯\n",
|
||
"────────────────────────────────────────────────────────────────\n",
|
||
" ⏵⏵ auto mode on · 1 shell · ← for agents /rc\n",
|
||
);
|
||
let result = osc_explain(Agent::Claude, screen, "", "");
|
||
|
||
assert_eq!(result.state, AgentState::Idle);
|
||
assert_eq!(
|
||
result.matched_rule.as_ref().map(|rule| rule.id.as_str()),
|
||
Some("live_prompt_box")
|
||
);
|
||
assert!(result.visible_idle);
|
||
assert!(!result.visible_working);
|
||
}
|
||
|
||
#[test]
|
||
fn claude_background_shell_without_foreground_evidence_is_idle_fallback() {
|
||
let result = osc_explain(
|
||
Agent::Claude,
|
||
" ⏵⏵ auto mode on · 1 shell · ← for agents\n",
|
||
"",
|
||
"",
|
||
);
|
||
|
||
assert_eq!(result.state, AgentState::Idle);
|
||
assert_eq!(result.matched_rule, None);
|
||
assert_eq!(
|
||
result.fallback_reason.as_deref(),
|
||
Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK)
|
||
);
|
||
assert!(!result.visible_working);
|
||
}
|
||
|
||
#[test]
|
||
fn claude_live_turn_with_background_shell_remains_working() {
|
||
let screen = concat!(
|
||
"────────────────────────────────────────────────────────────────\n",
|
||
"❯\n",
|
||
"────────────────────────────────────────────────────────────────\n",
|
||
" ⏵⏵ auto mode on · 1 shell · esc to interrupt\n",
|
||
);
|
||
let result = osc_explain(Agent::Claude, screen, "", "");
|
||
|
||
assert_eq!(result.state, AgentState::Working);
|
||
assert_eq!(
|
||
result.matched_rule.as_ref().map(|rule| rule.id.as_str()),
|
||
Some("live_turn_working")
|
||
);
|
||
assert!(result.visible_working);
|
||
}
|
||
|
||
#[test]
|
||
fn claude_blocker_with_background_shell_remains_blocked() {
|
||
let screen = concat!(
|
||
"do you want to proceed?\n",
|
||
"bash command: rm -rf /tmp/test\n",
|
||
"❯ 1. Yes\n",
|
||
" 2. No\n\n",
|
||
"Esc to cancel · Tab to amend · ctrl+e to explain\n",
|
||
" ⏵⏵ auto mode on · 1 shell · ← for agents\n",
|
||
);
|
||
let result = osc_explain(Agent::Claude, screen, "", "");
|
||
|
||
assert_eq!(result.state, AgentState::Blocked);
|
||
assert_eq!(
|
||
result.matched_rule.as_ref().map(|rule| rule.id.as_str()),
|
||
Some("bash_permission_prompt")
|
||
);
|
||
assert!(result.visible_blocker);
|
||
assert!(!result.visible_working);
|
||
}
|
||
|
||
#[test]
|
||
fn claude_osc_title_braille_prefix_is_working() {
|
||
// "⠂" is U+2802, in the braille block U+2800-U+28FF
|
||
let result = osc_explain(Agent::Claude, "", "⠂ project", "");
|
||
assert_eq!(result.state, AgentState::Working);
|
||
assert_eq!(
|
||
result.matched_rule.as_ref().map(|r| r.id.as_str()),
|
||
Some("osc_title_working")
|
||
);
|
||
assert!(result.visible_working);
|
||
}
|
||
|
||
#[test]
|
||
fn claude_osc_title_half_circle_frames_are_working() {
|
||
for frame in ['◐', '◓', '◑', '◒'] {
|
||
let title = format!("{frame} Initial conversation with Claude");
|
||
let result = osc_explain(Agent::Claude, "", &title, "");
|
||
assert_eq!(result.state, AgentState::Working, "frame {frame}");
|
||
assert_eq!(
|
||
result.matched_rule.as_ref().map(|rule| rule.id.as_str()),
|
||
Some("osc_title_working"),
|
||
"frame {frame}"
|
||
);
|
||
assert!(result.visible_working, "frame {frame}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn claude_osc_title_static_prefix_is_idle() {
|
||
// "✳" is U+2733, static prefix when Claude is not working
|
||
let result = osc_explain(Agent::Claude, "", "✳ Claude Code", "");
|
||
assert_eq!(result.state, AgentState::Idle);
|
||
assert_eq!(
|
||
result.matched_rule.as_ref().map(|r| r.id.as_str()),
|
||
Some("osc_title_idle")
|
||
);
|
||
assert!(result.visible_idle);
|
||
}
|
||
|
||
#[test]
|
||
fn claude_osc_progress_4_3_alone_does_not_force_working() {
|
||
// Claude leaves progress stuck at 4;3 while waiting for permission, so
|
||
// 4;3 must not be a working signal on its own. With no other evidence it
|
||
// falls back to idle; blocked screen rules can win when present.
|
||
let result = osc_explain(Agent::Claude, "", "", "4;3;");
|
||
assert_eq!(result.state, AgentState::Idle);
|
||
assert_eq!(
|
||
result.fallback_reason.as_deref(),
|
||
Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK)
|
||
);
|
||
assert!(!result.visible_working);
|
||
}
|
||
|
||
#[test]
|
||
fn claude_blocker_screen_outranks_stale_osc_progress() {
|
||
// Regression: progress 4;3 persists during permission prompts. The
|
||
// blocked form on screen must win because no rule treats 4;3 as working.
|
||
let blocker_screen =
|
||
"──────────\n 1. Yes\n 2. No\n\nEnter to select · ↑/↓ to navigate · Esc to cancel\n";
|
||
let result = osc_explain(Agent::Claude, blocker_screen, "✳ Task title", "4;3;");
|
||
assert_eq!(result.state, AgentState::Blocked);
|
||
assert!(result.visible_blocker);
|
||
}
|
||
|
||
#[test]
|
||
fn claude_osc_progress_4_0_is_idle() {
|
||
let result = osc_explain(Agent::Claude, "", "", "4;0;");
|
||
assert_eq!(result.state, AgentState::Idle);
|
||
assert_eq!(
|
||
result.matched_rule.as_ref().map(|r| r.id.as_str()),
|
||
Some("osc_progress_idle")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn claude_blocker_screen_outranks_osc_idle_title() {
|
||
// When the OSC title shows ✳ (idle) but the screen has a bash permission
|
||
// prompt, the blocked rule at priority 850 beats osc_title_idle at 250.
|
||
let blocker_screen = "do you want to proceed?\n\
|
||
bash command: rm -rf /tmp/test\n\
|
||
❯ 1. Yes\n 2. No\n\n\
|
||
Esc to cancel · Tab to amend · ctrl+e to explain\n";
|
||
let result = osc_explain(Agent::Claude, blocker_screen, "✳ Claude Code", "");
|
||
assert_eq!(result.state, AgentState::Blocked);
|
||
assert!(result.visible_blocker);
|
||
}
|
||
|
||
#[test]
|
||
fn claude_mcp_elicitation_is_blocked() {
|
||
// Regression for issue #3283: an MCP elicitation dialog has Accept/Decline
|
||
// controls and an "Esc to cancel" footer but no Enter hint, so no blocked
|
||
// rule matched and the static OSC title reported idle.
|
||
// Live capture uses curly quotes around the server name; the issue report
|
||
// transcribed straight quotes. Both must classify as blocked.
|
||
for screen in [
|
||
"MCP server \u{201c}my-server\u{201d} requests your input\n\nGrant temporary access to the demo gateway for 15 minutes?\n\n\u{276f} Accept Decline\n\nEsc to cancel \u{b7} \u{2191}/\u{2193} to navigate\n",
|
||
"MCP server \"my-server\" requests your input\n\nserver-supplied message\n\n\u{276f} Accept Decline\n\nEsc to cancel \u{b7} \u{2191}/\u{2193} to navigate\n",
|
||
] {
|
||
let result = with_manifest_dirs("claude-mcp-elicitation", || {
|
||
osc_explain(Agent::Claude, screen, "\u{2733} Claude Code", "")
|
||
});
|
||
assert_eq!(result.state, AgentState::Blocked, "{result:#?}");
|
||
assert!(result.visible_blocker, "{result:#?}");
|
||
assert_eq!(
|
||
result.matched_rule.as_ref().map(|r| r.id.as_str()),
|
||
Some("mcp_elicitation_prompt"),
|
||
"{result:#?}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn claude_empty_osc_empty_screen_is_idle_fallback() {
|
||
// No OSC data, no matching screen rule → fallback idle (unchanged V3 behavior)
|
||
let result = osc_explain(Agent::Claude, "", "", "");
|
||
assert_eq!(result.state, AgentState::Idle);
|
||
assert_eq!(
|
||
result.fallback_reason.as_deref(),
|
||
Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK)
|
||
);
|
||
assert!(!result.visible_idle);
|
||
}
|
||
|
||
// --- Codex OSC rules ---
|
||
|
||
#[test]
|
||
fn codex_osc_title_braille_spinner_is_working() {
|
||
// "⠋" is U+280B, in the braille block
|
||
let result = osc_explain(Agent::Codex, "", "⠋ llm-proxy", "");
|
||
assert_eq!(result.state, AgentState::Working);
|
||
assert_eq!(
|
||
result.matched_rule.as_ref().map(|r| r.id.as_str()),
|
||
Some("osc_title_working")
|
||
);
|
||
assert!(result.visible_working);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_osc_title_action_required_is_blocked() {
|
||
let result = osc_explain(Agent::Codex, "", "[ . ] Action Required | llm-proxy", "");
|
||
assert_eq!(result.state, AgentState::Blocked);
|
||
assert_eq!(
|
||
result.matched_rule.as_ref().map(|r| r.id.as_str()),
|
||
Some("osc_title_blocked")
|
||
);
|
||
assert!(result.visible_blocker);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_osc_title_plain_is_idle() {
|
||
let result = osc_explain(Agent::Codex, "", "llm-proxy", "");
|
||
assert_eq!(result.state, AgentState::Idle);
|
||
assert_eq!(
|
||
result.matched_rule.as_ref().map(|r| r.id.as_str()),
|
||
Some("osc_title_idle")
|
||
);
|
||
assert!(result.visible_idle);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_trust_directory_requires_live_top_region() {
|
||
let screen = "> You are in C:\\Users\\user\\project\n\n\
|
||
Do you trust the contents of this\n\
|
||
directory? Working with untrusted\n\
|
||
contents comes with higher risk of\n\
|
||
prompt injection. Trusting the\n\
|
||
directory allows project-local config,\n\
|
||
hooks, and exec policies to load.\n\n\
|
||
› 1. Yes, continue\n\
|
||
2. No, quit\n\n\
|
||
Press enter to continue\n";
|
||
let result = osc_explain(Agent::Codex, screen, "project", "");
|
||
|
||
assert_eq!(result.state, AgentState::Blocked);
|
||
assert_eq!(
|
||
result.matched_rule.as_ref().map(|rule| rule.id.as_str()),
|
||
Some("trust_directory")
|
||
);
|
||
assert!(result.visible_blocker);
|
||
|
||
let transcript = "› > You are in C:\\Users\\user\\project\n\n\
|
||
Do you trust the contents of this\n\
|
||
directory? Working with untrusted contents comes with higher risk.\n";
|
||
let result = osc_explain(Agent::Codex, transcript, "project", "");
|
||
|
||
assert_eq!(result.state, AgentState::Idle);
|
||
assert_ne!(
|
||
result.matched_rule.as_ref().map(|rule| rule.id.as_str()),
|
||
Some("trust_directory")
|
||
);
|
||
assert!(!result.visible_blocker);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_background_terminal_screen_does_not_override_osc_idle() {
|
||
// Background terminal tasks can be long-lived helpers such as dev servers.
|
||
// They should not make Codex look busy once the foreground turn is idle.
|
||
let screen = "background terminal running · /ps to view · /stop to close\n";
|
||
let result = osc_explain(Agent::Codex, screen, "llm-proxy", "");
|
||
assert_eq!(result.state, AgentState::Idle);
|
||
assert_eq!(
|
||
result.matched_rule.as_ref().map(|r| r.id.as_str()),
|
||
Some("osc_title_idle")
|
||
);
|
||
assert!(result.visible_idle);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_screen_working_fallback_handles_static_osc_title() {
|
||
let screen = "• I’ll run it and wait for completion.\n\n\
|
||
◦ Working (1m 16s • esc to interrupt) · 1 background…\n\n\
|
||
› Use /skills to list available skills\n\n\
|
||
gpt-5.6-sol default · /work\n";
|
||
let result = osc_explain(Agent::Codex, screen, "project", "");
|
||
|
||
assert_eq!(result.state, AgentState::Working);
|
||
assert_eq!(
|
||
result.matched_rule.as_ref().map(|r| r.id.as_str()),
|
||
Some("screen_working_fallback")
|
||
);
|
||
assert!(result.visible_working);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_osc_working_remains_preferred_over_screen_fallback() {
|
||
let screen = "• Working (4s • esc to interrupt)\n\n\
|
||
› Use /skills to list available skills\n\n\
|
||
gpt-5.6-sol default · /work\n";
|
||
let result = osc_explain(Agent::Codex, screen, "⠸ project", "");
|
||
|
||
assert_eq!(result.state, AgentState::Working);
|
||
assert_eq!(
|
||
result.matched_rule.as_ref().map(|r| r.id.as_str()),
|
||
Some("osc_title_working")
|
||
);
|
||
assert!(result.visible_working);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_screen_blocker_outranks_working_fallback() {
|
||
let screen = "• Working (4s • esc to interrupt)\n\
|
||
› 1. Yes, proceed\n\
|
||
Press enter to confirm or esc to cancel\n";
|
||
let result = osc_explain(Agent::Codex, screen, "project", "");
|
||
|
||
assert_eq!(result.state, AgentState::Blocked);
|
||
assert_eq!(
|
||
result.matched_rule.as_ref().map(|r| r.id.as_str()),
|
||
Some("live_strong_blocker")
|
||
);
|
||
assert!(result.visible_blocker);
|
||
assert!(!result.visible_working);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_weak_blocker_without_current_prompt_is_blocked() {
|
||
let result = osc_explain(
|
||
Agent::Codex,
|
||
"do you want to continue? [y/n]\n",
|
||
"project",
|
||
"",
|
||
);
|
||
|
||
assert_eq!(result.state, AgentState::Blocked);
|
||
assert_eq!(
|
||
result.matched_rule.as_ref().map(|r| r.id.as_str()),
|
||
Some("weak_blocker")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_current_prompt_keeps_weak_text_from_overriding_working_fallback() {
|
||
let screen = "• Working (4s • esc to interrupt)\n\
|
||
do you want to continue? [y/n]\n\
|
||
› Use /skills to list available skills\n";
|
||
let result = osc_explain(Agent::Codex, screen, "project", "");
|
||
|
||
assert_eq!(result.state, AgentState::Working);
|
||
assert_eq!(
|
||
result.matched_rule.as_ref().map(|r| r.id.as_str()),
|
||
Some("screen_working_fallback")
|
||
);
|
||
assert!(result.visible_working);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_weak_blocker_ignores_finished_response_above_current_prompt() {
|
||
let screen = "• The `wt rm` transcript now shows [y/N] / esc, matching the real prompt.\n\n\
|
||
─ Worked for 4m 59s ─\n\n\
|
||
› Ask Codex to do anything\n";
|
||
let result = osc_explain(Agent::Codex, screen, "project", "");
|
||
|
||
assert_eq!(result.state, AgentState::Idle);
|
||
assert_eq!(
|
||
result.matched_rule.as_ref().map(|r| r.id.as_str()),
|
||
Some("osc_title_idle")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_weak_blocker_ignores_wrapped_current_prompt_text() {
|
||
let screen = "› Explain why this prompt wraps before quoting the confirmation text\n\
|
||
[y/N] / esc and whether the docs should include it\n\n\
|
||
gpt-5.6-sol default · /work\n";
|
||
let result = osc_explain(Agent::Codex, screen, "project", "");
|
||
|
||
assert_eq!(result.state, AgentState::Idle);
|
||
assert_eq!(
|
||
result.matched_rule.as_ref().map(|r| r.id.as_str()),
|
||
Some("osc_title_idle")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_transcript_viewer_outranks_working_fallback() {
|
||
let screen = "• Working (4s • esc to interrupt)\n\
|
||
› transcript\n\
|
||
↑/↓ to scroll · pgup/pgdn to move · home/end to jump · q to quit · esc to edit prev\n";
|
||
let result = osc_explain(Agent::Codex, screen, "project", "");
|
||
|
||
assert_eq!(result.state, AgentState::Unknown);
|
||
assert_eq!(
|
||
result.matched_rule.as_ref().map(|r| r.id.as_str()),
|
||
Some("transcript_viewer")
|
||
);
|
||
assert!(result.skip_state_update);
|
||
assert!(!result.visible_working);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_screen_working_fallback_ignores_stale_and_prompt_text() {
|
||
let screens = [
|
||
"◦ Working (1m 16s • esc to interrupt)\n\
|
||
■ Conversation interrupted\n\
|
||
› Use /skills to list available skills\n\
|
||
gpt-5.6-sol default · /work\n",
|
||
"› Explain the text ◦ Working (1m 16s • esc to interrupt)\n\
|
||
gpt-5.6-sol default · /work\n",
|
||
" ◦ Working (1m 16s • esc to interrupt)\n\
|
||
› Use /skills to list available skills\n\
|
||
gpt-5.6-sol default · /work\n",
|
||
];
|
||
|
||
for screen in screens {
|
||
let result = osc_explain(Agent::Codex, screen, "project", "");
|
||
assert_eq!(result.state, AgentState::Idle);
|
||
assert_eq!(
|
||
result.matched_rule.as_ref().map(|r| r.id.as_str()),
|
||
Some("osc_title_idle")
|
||
);
|
||
assert!(result.visible_idle);
|
||
assert!(!result.visible_working);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn codex_screen_working_fallback_ignores_interrupted_short_terminal() {
|
||
let screen = "◦ Working (1m 16s • esc to interrupt)\n\
|
||
■ Conversation interrupted\n\
|
||
›\n";
|
||
let result = osc_explain(Agent::Codex, screen, "project", "");
|
||
|
||
assert_eq!(result.state, AgentState::Idle);
|
||
assert_eq!(
|
||
result.matched_rule.as_ref().map(|r| r.id.as_str()),
|
||
Some("osc_title_idle")
|
||
);
|
||
assert!(result.visible_idle);
|
||
assert!(!result.visible_working);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_osc_working_beats_weak_blocker_screen() {
|
||
// A stale [y/n] on screen triggers weak_blocker at priority 600, but an
|
||
// active braille spinner in the OSC title is priority 1050 — OSC wins.
|
||
let screen = "do you want to continue? [y/n]\n";
|
||
let result = osc_explain(Agent::Codex, screen, "⠋ llm-proxy", "");
|
||
assert_eq!(result.state, AgentState::Working);
|
||
assert_eq!(
|
||
result.matched_rule.as_ref().map(|r| r.id.as_str()),
|
||
Some("osc_title_working")
|
||
);
|
||
}
|