Skip to content

Commit 7c0c224

Browse files
patricka3125claude
andcommitted
fix(opencode): handle Nm Ns duration format and extend extraction buffer
Two status/extraction bugs revealed by e2e runs with full system prompts: 1. COMPLETION_MARKER_PATTERN now matches the Nm Ns duration format that OpenCode emits for responses that take more than 60 seconds (e.g. "1m 8s"). The old pattern only matched the pure-seconds form, causing get_status() to stall at PROCESSING indefinitely for longer turns. 2. Add extraction_tail_lines property to BaseProvider (default None) and override to 2000 in OpenCodeCliProvider. terminal_service.get_output uses this value for the LAST-mode tmux capture so long responses don't push the user-message marker (┃ ) beyond the 200-line default window. Status-check captures are unaffected. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3d70846 commit 7c0c224

4 files changed

Lines changed: 55 additions & 3 deletions

File tree

src/cli_agent_orchestrator/providers/base.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,18 @@ def extraction_retries(self) -> int:
128128
"""
129129
return 0
130130

131+
@property
132+
def extraction_tail_lines(self) -> Optional[int]:
133+
"""Number of scrollback lines to capture for output extraction.
134+
135+
Output extraction needs more history than status detection (which only
136+
needs the last few lines for footer/marker checks). Override in
137+
providers whose agents produce long responses that may push the
138+
user-message marker beyond ``TMUX_HISTORY_LINES``. ``None`` uses
139+
the default ``TMUX_HISTORY_LINES`` constant.
140+
"""
141+
return None
142+
131143
@abstractmethod
132144
def extract_last_message_from_script(self, script_output: str) -> str:
133145
"""Extract the last message from terminal script output.

src/cli_agent_orchestrator/providers/opencode_cli.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,11 @@
4040
# User message indent: blue vertical bar + 2 spaces
4141
USER_MESSAGE_PATTERN = r"^┃\s{2}"
4242

43-
# Per-turn completion marker: "▣ <agent> · <model> · <duration>s"
43+
# Per-turn completion marker: "▣ <agent> · <model> · <duration>"
4444
# Two middle-dot separators and a trailing duration are required.
45-
COMPLETION_MARKER_PATTERN = r"▣\s+\S+\s+·\s+.+?\s+·\s+\d+(?:\.\d+)?s"
45+
# OpenCode formats duration as "Ns" for short runs and "Nm Ns" once the turn
46+
# exceeds 60 seconds (e.g. "1m 8s"). Both forms must be matched.
47+
COMPLETION_MARKER_PATTERN = r"▣\s+\S+\s+·\s+.+?\s+·\s+(?:\d+m\s+)?\d+(?:\.\d+)?s"
4648

4749
# Processing footer — keybind hint present while the agent is generating.
4850
PROCESSING_FOOTER_PATTERN = r"\besc interrupt\b"
@@ -100,6 +102,18 @@ def paste_enter_count(self) -> int:
100102
"""OpenCode TUI submits on a single Enter after bracketed paste."""
101103
return 1
102104

105+
@property
106+
def extraction_tail_lines(self) -> int:
107+
"""Use a larger scrollback window for extraction.
108+
109+
OpenCode renders the full conversation in the TUI scrollback. With
110+
detailed system prompts, agents can produce responses long enough to
111+
push the user-message marker (┃ ) beyond the default 200-line capture
112+
window, causing "No user message found" extraction failures. 2000
113+
matches the tmux history-limit so we capture the full available buffer.
114+
"""
115+
return 2000
116+
103117
def initialize(self) -> bool:
104118
"""Start the OpenCode TUI and wait for the idle splash frame.
105119

src/cli_agent_orchestrator/services/terminal_service.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,10 @@ def get_output(terminal_id: str, mode: OutputMode = OutputMode.FULL) -> str:
374374
retries extraction with 10 s delays between attempts. This handles
375375
TUI-based providers (e.g. Gemini CLI's Ink renderer) whose notification
376376
spinners can temporarily obscure response text in the tmux capture buffer.
377+
378+
If the provider declares ``extraction_tail_lines``, the history capture
379+
for LAST mode uses that value instead of the default ``TMUX_HISTORY_LINES``.
380+
Status-check captures are unaffected (they go through get_status directly).
377381
"""
378382
try:
379383
metadata = get_terminal_metadata(terminal_id)
@@ -389,14 +393,27 @@ def get_output(terminal_id: str, mode: OutputMode = OutputMode.FULL) -> str:
389393
if provider is None:
390394
raise ValueError(f"Provider not found for terminal {terminal_id}")
391395

396+
# Re-capture with provider's preferred extraction window if it differs
397+
# from the default (status checks use TMUX_HISTORY_LINES; long-response
398+
# providers need more lines so user-message markers don't scroll off).
399+
extract_lines = provider.extraction_tail_lines
400+
if extract_lines is not None:
401+
full_output = tmux_client.get_history(
402+
metadata["tmux_session"],
403+
metadata["tmux_window"],
404+
tail_lines=extract_lines,
405+
)
406+
392407
retries = provider.extraction_retries
393408
last_err: Exception | None = None
394409
for attempt in range(1 + retries):
395410
try:
396411
if attempt > 0:
397412
time.sleep(10.0)
398413
full_output = tmux_client.get_history(
399-
metadata["tmux_session"], metadata["tmux_window"]
414+
metadata["tmux_session"],
415+
metadata["tmux_window"],
416+
tail_lines=extract_lines,
400417
)
401418
return provider.extract_last_message_from_script(full_output)
402419
except ValueError as exc:

test/providers/test_opencode_cli_unit.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ def test_user_message_pattern_rejects_plain_text(self):
5858
def test_completion_marker_pattern_matches_full_marker(self):
5959
assert re.search(COMPLETION_MARKER_PATTERN, "▣ Build · Big Pickle · 7.2s")
6060

61+
def test_completion_marker_pattern_matches_minute_second_duration(self):
62+
# Responses > 60 s are formatted as "Nm Ns" by OpenCode.
63+
assert re.search(COMPLETION_MARKER_PATTERN, "▣ Data_analyst · Big Pickle · 1m 8s")
64+
assert re.search(COMPLETION_MARKER_PATTERN, "▣ Data_analyst · Big Pickle · 2m 30.5s")
65+
6166
def test_completion_marker_pattern_rejects_incomplete(self):
6267
# No duration suffix → not a full completion marker
6368
assert not re.search(COMPLETION_MARKER_PATTERN, "▣ Build · Big Pickle")
@@ -386,6 +391,10 @@ def test_get_idle_pattern_for_log_returns_ctrl_p_pattern(self):
386391
def test_paste_enter_count_is_one(self):
387392
assert make_provider().paste_enter_count == 1
388393

394+
def test_extraction_tail_lines_is_2000(self):
395+
"""extraction_tail_lines must be large enough for long-response agents."""
396+
assert make_provider().extraction_tail_lines == 2000
397+
389398

390399
# ---------------------------------------------------------------------------
391400
# Provider manager registration

0 commit comments

Comments
 (0)