aboutsummaryrefslogtreecommitdiff
path: root/src/detect/manifest
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-10 19:23:13 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-10 19:23:13 +0300
commit82468a0e03360219898ebf3735bdf559182009f8 (patch)
tree0ee5213fe041a539f23e846f9148493528249512 /src/detect/manifest
parent36a1b7f86444871f0c38ff36510a6e3c2eec48c6 (diff)
feat: add manifest-based detection engine
Diffstat (limited to 'src/detect/manifest')
-rw-r--r--src/detect/manifest/tests.rs204
1 files changed, 191 insertions, 13 deletions
diff --git a/src/detect/manifest/tests.rs b/src/detect/manifest/tests.rs
index e5b03c0b..0f425384 100644
--- a/src/detect/manifest/tests.rs
+++ b/src/detect/manifest/tests.rs
@@ -156,7 +156,7 @@ line_regex = ["^exact line$"]
#[test]
fn remote_manifest_loads_between_local_override_and_bundled() {
with_manifest_dirs("remote-source", || {
- write_remote_codex(&remote_manifest("2026.06.10.1", "blocked", "remote-ready"));
+ write_remote_codex(&remote_manifest("2026.06.10.3", "blocked", "remote-ready"));
let explain = explain(Agent::Codex, "remote-ready");
@@ -165,10 +165,10 @@ fn remote_manifest_loads_between_local_override_and_bundled() {
explain.source,
Some(ManifestSource::Remote { .. })
));
- assert_eq!(explain.manifest_version.as_deref(), Some("2026.06.10.1"));
+ assert_eq!(explain.manifest_version.as_deref(), Some("2026.06.10.3"));
assert_eq!(
explain.cached_remote_version.as_deref(),
- Some("2026.06.10.1")
+ Some("2026.06.10.3")
);
});
}
@@ -176,7 +176,7 @@ fn remote_manifest_loads_between_local_override_and_bundled() {
#[test]
fn fallback_explain_preserves_active_manifest_version() {
with_manifest_dirs("fallback-version", || {
- write_remote_codex(&remote_manifest("2026.06.10.1", "blocked", "remote-ready"));
+ write_remote_codex(&remote_manifest("2026.06.10.3", "blocked", "remote-ready"));
let explain = explain(Agent::Codex, "ordinary prompt text");
@@ -185,7 +185,7 @@ fn fallback_explain_preserves_active_manifest_version() {
explain.fallback_reason.as_deref(),
Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK)
);
- assert_eq!(explain.manifest_version.as_deref(), Some("2026.06.10.1"));
+ assert_eq!(explain.manifest_version.as_deref(), Some("2026.06.10.3"));
assert!(matches!(
explain.source,
Some(ManifestSource::Remote { .. })
@@ -216,7 +216,7 @@ fn older_cached_remote_manifest_does_not_shadow_newer_bundled_manifest() {
#[test]
fn local_override_shadows_cached_remote_manifest() {
with_manifest_dirs("local-shadows-remote", || {
- write_remote_codex(&remote_manifest("2026.06.10.1", "blocked", "remote-ready"));
+ write_remote_codex(&remote_manifest("2026.06.10.3", "blocked", "remote-ready"));
write_local_codex(&local_manifest("idle", "local-ready"));
let explain = explain(Agent::Codex, "local-ready");
@@ -226,7 +226,7 @@ fn local_override_shadows_cached_remote_manifest() {
assert!(explain.local_override_shadowing_remote);
assert_eq!(
explain.cached_remote_version.as_deref(),
- Some("2026.06.10.1")
+ Some("2026.06.10.3")
);
});
}
@@ -234,7 +234,7 @@ fn local_override_shadows_cached_remote_manifest() {
#[test]
fn invalid_local_override_falls_back_to_cached_remote_manifest() {
with_manifest_dirs("invalid-local-remote-fallback", || {
- write_remote_codex(&remote_manifest("2026.06.10.1", "blocked", "remote-ready"));
+ write_remote_codex(&remote_manifest("2026.06.10.3", "blocked", "remote-ready"));
write_local_codex("id = ");
let explain = explain(Agent::Codex, "remote-ready");
@@ -251,7 +251,7 @@ fn invalid_local_override_falls_back_to_cached_remote_manifest() {
#[test]
fn detection_uses_cached_manifest_until_explicit_reload() {
with_manifest_dirs("cache-boundary", || {
- write_remote_codex(&remote_manifest("2026.06.10.1", "blocked", "cached-ready"));
+ write_remote_codex(&remote_manifest("2026.06.10.3", "blocked", "cached-ready"));
let cached = explain(Agent::Codex, "cached-ready");
assert_eq!(cached.state, AgentState::Blocked);
@@ -261,7 +261,7 @@ fn detection_uses_cached_manifest_until_explicit_reload() {
Some("test")
);
- write_remote_codex_without_reload(&remote_manifest("2026.06.10.2", "working", "new-ready"));
+ write_remote_codex_without_reload(&remote_manifest("2026.06.10.4", "working", "new-ready"));
let unchanged = explain(Agent::Codex, "new-ready");
assert_eq!(unchanged.state, AgentState::Idle);
@@ -271,7 +271,7 @@ fn detection_uses_cached_manifest_until_explicit_reload() {
);
assert_eq!(
unchanged.cached_remote_version.as_deref(),
- Some("2026.06.10.1")
+ Some("2026.06.10.3")
);
reload_manifests();
@@ -280,7 +280,7 @@ fn detection_uses_cached_manifest_until_explicit_reload() {
assert_eq!(reloaded.state, AgentState::Working);
assert_eq!(
reloaded.cached_remote_version.as_deref(),
- Some("2026.06.10.2")
+ Some("2026.06.10.4")
);
assert_eq!(
reloaded.matched_rule.as_ref().map(|rule| rule.id.as_str()),
@@ -492,7 +492,185 @@ fn bottom_non_empty_lines_uses_bottom_occurrence_for_repeated_text() {
let content = "marker\nold\n\nmiddle\nmarker\nnew\n";
assert_eq!(
- region(content, "bottom_non_empty_lines(2)"),
+ region(
+ DetectionInput {
+ screen: content,
+ osc_title: "",
+ osc_progress: "",
+ },
+ "bottom_non_empty_lines(2)"
+ ),
"marker\nnew\n"
);
}
+
+// ---------------------------------------------------------------------------
+// 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_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_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_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_background_terminal_screen_beats_osc_idle() {
+ // Codex doesn't set is_task_running() for background terminals, so title is
+ // plain (idle). The screen rule background_terminal_working at priority 800
+ // beats osc_title_idle at priority 100.
+ 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::Working);
+ assert_eq!(
+ result.matched_rule.as_ref().map(|r| r.id.as_str()),
+ Some("background_terminal_working")
+ );
+ 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")
+ );
+}