Skip to content

Commit eda4da2

Browse files
authored
fix(claude-code): eliminate PROCESSING false-positives from compaction and /exit (#199)
Replace the regex-based THINKING_BEFORE_SEPARATOR_PATTERN.search() with a last-separator-anchored walk-back algorithm in get_status(). The old regex matched any spinner+ellipsis line before *any* separator in the scrollback, causing two false-positive patterns: 1. Mid-conversation compaction: '✢ Compacting…' → sep → more output → last sep The compaction spinner sits before its own separator, not the last one. 2. Post-exit: spinner → sep (task done) → ❯ /exit → last sep (exit menu) The spinner belongs to the completed task, not the exit separator. The new algorithm walks backwards from the last separator only. If it hits a spinner line before another separator, the agent is genuinely processing. If it hits another separator first, the spinner is from a completed task. Adds three regression tests covering both false-positive patterns.
1 parent 2cad3e5 commit eda4da2

2 files changed

Lines changed: 67 additions & 17 deletions

File tree

src/cli_agent_orchestrator/providers/claude_code.py

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -34,18 +34,11 @@ class ProviderError(Exception):
3434
# - Minimal format: "✻ Orbiting…" (no parenthesized status)
3535
# Common: spinner char + text + ellipsis, optionally followed by parenthesized status
3636
PROCESSING_PATTERN = r"[✶✢✽✻✳·].*\u2026"
37-
# Structural PROCESSING indicator: a spinner line (spinner char + … ) immediately
38-
# before the ──────── separator line that Claude Code draws before the input prompt.
39-
# Requires a known spinner character on the same line as … to avoid false-positives
40-
# from response text or tool outputs that happen to contain … .
41-
# Allows 0–2 blank lines between the spinner and the separator.
42-
# The separator line starts with an ANSI colour code (\x1b[38;5;244m) before the
43-
# box-drawing characters (U+2500), so the pattern skips that prefix explicitly.
44-
#
45-
# Processing: "✻ Skedaddling…\n\n────────\n❯ " → spinner+… before separator → PROCESSING
46-
# Idle/done: "⏺ response text\n────────\n❯ " → no spinner before separator → not PROCESSING
47-
# Stale spinner: "✢ Thinking…" far back in scrollback, current separator has
48-
# no spinner immediately before it → not PROCESSING
37+
# Structural PROCESSING indicator (reference pattern — get_status uses an
38+
# inline last-separator-anchored version to avoid false positives from
39+
# mid-conversation compaction events like "✢ Compacting conversation…"):
40+
# a spinner line (spinner char + … ) immediately before the ────────
41+
# separator, allowing 0–2 blank lines between them.
4942
THINKING_BEFORE_SEPARATOR_PATTERN = re.compile(
5043
r"[^\n]*[✶✢✽✻✳·][^\n]*\u2026[^\n]*\n(?:[^\n]*\n){0,2}(?:\x1b\[[0-9;]*m)*\u2500{20,}",
5144
re.MULTILINE,
@@ -335,11 +328,22 @@ def get_status(self, tail_lines: Optional[int] = None) -> TerminalStatus:
335328
if not output:
336329
return TerminalStatus.ERROR
337330

338-
# PRIMARY PROCESSING check: structural — thinking line immediately
339-
# before the ──────── separator. Catches ALL spinner variants (including
340-
# newer "· Swirling…" format) and is immune to the ❯ position problem.
341-
if THINKING_BEFORE_SEPARATOR_PATTERN.search(output):
342-
return TerminalStatus.PROCESSING
331+
# PRIMARY PROCESSING check: walk backwards from the *last* separator.
332+
# If we encounter a spinner line (spinner char + …) before we encounter
333+
# another separator, the agent is actively processing.
334+
# If we hit another separator first, the spinner belongs to a previously
335+
# completed task — covers two distinct false-positive patterns:
336+
# 1. Mid-conversation compaction: "✢ Compacting…" → sep → more output → last sep
337+
# 2. Post-exit: live spinner → sep (task done) → ❯ /exit → last sep (exit menu)
338+
_sep_re = re.compile(r"(?:\x1b\[[0-9;]*m)*\u2500{20,}")
339+
_sep_positions = [m.start() for m in _sep_re.finditer(output)]
340+
if _sep_positions:
341+
pre_sep_lines = output[: _sep_positions[-1]].rstrip("\n").split("\n")
342+
for line in reversed(pre_sep_lines):
343+
if re.search(r"[✶✢✽✻✳·][^\n]*\u2026", line):
344+
return TerminalStatus.PROCESSING # spinner before another separator
345+
if _sep_re.search(line):
346+
break # hit another separator first — spinner is from a completed task
343347

344348
# Find the LAST occurrence of each marker for fallback position checks.
345349
last_processing = None

test/providers/test_claude_code_unit.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,52 @@ def test_get_status_with_tail_lines(self, mock_tmux):
425425

426426
mock_tmux.get_history.assert_called_with("test-session", "window-0", tail_lines=50)
427427

428+
@patch("cli_agent_orchestrator.providers.claude_code.tmux_client")
429+
def test_get_status_completed_after_compaction_not_false_processing(self, mock_tmux):
430+
"""Compaction spinner before its own separator, then more output; last sep has no spinner → COMPLETED."""
431+
mock_tmux.get_history.return_value = (
432+
"❯ do the task\n"
433+
"⏺ Starting work…\n"
434+
"✢ Compacting conversation…\n"
435+
"────────────────────────\n"
436+
"⏺ Here is the completed response\n"
437+
"────────────────────────\n"
438+
"❯ "
439+
)
440+
provider = ClaudeCodeProvider("test123", "test-session", "window-0")
441+
assert provider.get_status() == TerminalStatus.COMPLETED
442+
443+
@patch("cli_agent_orchestrator.providers.claude_code.tmux_client")
444+
def test_get_status_processing_after_compaction_when_still_running(self, mock_tmux):
445+
"""Spinner before the last separator (agent resumes after compaction) → PROCESSING."""
446+
mock_tmux.get_history.return_value = (
447+
"❯ do the task\n"
448+
"✢ Compacting conversation…\n"
449+
"────────────────────────\n"
450+
"⏺ Resuming work…\n"
451+
"✻ Orbiting…\n"
452+
"────────────────────────\n"
453+
"❯ "
454+
)
455+
provider = ClaudeCodeProvider("test123", "test-session", "window-0")
456+
assert provider.get_status() == TerminalStatus.PROCESSING
457+
458+
@patch("cli_agent_orchestrator.providers.claude_code.tmux_client")
459+
def test_get_status_completed_after_exit_not_false_processing(self, mock_tmux):
460+
"""Spinner → sep (task done) → /exit → second sep; spinner NOT before last sep → not PROCESSING."""
461+
mock_tmux.get_history.return_value = (
462+
"❯ do the task\n"
463+
"⏺ Working on it…\n"
464+
"✻ Orbiting…\n"
465+
"────────────────────────\n"
466+
"❯ /exit\n"
467+
"⏺ Goodbye!\n"
468+
"────────────────────────\n"
469+
"❯ "
470+
)
471+
provider = ClaudeCodeProvider("test123", "test-session", "window-0")
472+
assert provider.get_status() != TerminalStatus.PROCESSING
473+
428474

429475
class TestClaudeCodeProviderMessageExtraction:
430476
"""Tests for ClaudeCodeProvider message extraction."""

0 commit comments

Comments
 (0)