Skip to content

Commit c1356ca

Browse files
authored
Merge branch 'main' into feat/opencli-integration
2 parents 92bfabf + 07a8a48 commit c1356ca

4 files changed

Lines changed: 99 additions & 27 deletions

File tree

src/cli_agent_orchestrator/mcp_server/server.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -491,7 +491,23 @@ def _assign_impl(
491491
# Create terminal
492492
terminal_id, _ = _create_terminal(agent_profile, working_directory)
493493

494-
# Send message immediately (auto-injects sender terminal ID suffix when enabled)
494+
# Guard: wait for the terminal to be genuinely ready before sending
495+
# the task message. create_terminal() calls provider.initialize() which
496+
# already waits 30 s for IDLE, but that check can return a false-positive
497+
# on the pre-existing shell ❯ prompt (zsh/bash) before claude starts.
498+
# A secondary API-level wait (same as handoff uses) catches that race.
499+
if not wait_until_terminal_status(
500+
terminal_id,
501+
{TerminalStatus.IDLE, TerminalStatus.COMPLETED},
502+
timeout=60.0,
503+
):
504+
return {
505+
"success": False,
506+
"terminal_id": terminal_id,
507+
"message": f"Terminal {terminal_id} did not reach ready status within 60 seconds — agent may not have started",
508+
}
509+
510+
# Send message (auto-injects sender terminal ID suffix when enabled)
495511
_send_direct_input_assign(terminal_id, message)
496512

497513
return {

src/cli_agent_orchestrator/providers/claude_code.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,13 @@ def initialize(self) -> bool:
254254
# Build properly escaped command string
255255
command = self._build_claude_command()
256256

257+
# Snapshot current pane content before sending the command.
258+
# We use this to distinguish the pre-existing shell ❯ prompt (zsh/bash)
259+
# from Claude Code's own ❯ REPL prompt — they are visually identical
260+
# after ANSI stripping, so without a snapshot, status detection can
261+
# falsely return IDLE on the old shell prompt before claude even starts.
262+
pre_launch_snapshot = tmux_client.get_history(self.session_name, self.window_name) or ""
263+
257264
# Send Claude Code command using tmux client
258265
tmux_client.send_keys(self.session_name, self.window_name, command)
259266

@@ -263,12 +270,28 @@ def initialize(self) -> bool:
263270
# Wait for Claude Code prompt to be ready.
264271
# Accept both IDLE and COMPLETED — some CLI versions show a startup
265272
# message that get_status() interprets as a completed response.
266-
if not wait_until_status(
267-
self,
268-
{TerminalStatus.IDLE, TerminalStatus.COMPLETED},
269-
timeout=30.0,
270-
polling_interval=1.0,
271-
):
273+
#
274+
# We require that new content appeared beyond the pre-launch snapshot
275+
# before accepting IDLE, to avoid the false-positive where the old zsh
276+
# ❯ prompt triggers an immediate IDLE return before claude starts.
277+
deadline = time.time() + 30.0
278+
while time.time() < deadline:
279+
current_output = tmux_client.get_history(self.session_name, self.window_name) or ""
280+
new_content = current_output[len(pre_launch_snapshot) :]
281+
# Claude-specific startup markers that cannot come from the shell:
282+
# the ──────── separator, bypass/trust prompt text, or "Claude Code"
283+
claude_started = bool(
284+
re.search(r"\u2500{20,}", new_content)
285+
or re.search(
286+
r"bypass permissions|trust this folder|Claude Code", new_content, re.IGNORECASE
287+
)
288+
)
289+
if claude_started:
290+
status = self.get_status()
291+
if status in {TerminalStatus.IDLE, TerminalStatus.COMPLETED}:
292+
break
293+
time.sleep(1.0)
294+
else:
272295
raise TimeoutError("Claude Code initialization timed out after 30 seconds")
273296

274297
self._initialized = True

test/mcp_server/test_assign.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@ class TestAssignSenderIdInjection:
1313

1414
@patch("cli_agent_orchestrator.mcp_server.server.ENABLE_SENDER_ID_INJECTION", True)
1515
@patch("cli_agent_orchestrator.mcp_server.server._send_direct_input")
16+
@patch("cli_agent_orchestrator.mcp_server.server.wait_until_terminal_status", return_value=True)
1617
@patch("cli_agent_orchestrator.mcp_server.server._create_terminal")
17-
def test_assign_appends_sender_id_when_injection_enabled(self, mock_create, mock_send):
18+
def test_assign_appends_sender_id_when_injection_enabled(
19+
self, mock_create, mock_wait, mock_send
20+
):
1821
"""When injection is enabled, assign should append sender ID suffix."""
1922
from cli_agent_orchestrator.mcp_server.server import _assign_impl
2023

@@ -33,8 +36,9 @@ def test_assign_appends_sender_id_when_injection_enabled(self, mock_create, mock
3336

3437
@patch("cli_agent_orchestrator.mcp_server.server.ENABLE_SENDER_ID_INJECTION", False)
3538
@patch("cli_agent_orchestrator.mcp_server.server._send_direct_input")
39+
@patch("cli_agent_orchestrator.mcp_server.server.wait_until_terminal_status", return_value=True)
3640
@patch("cli_agent_orchestrator.mcp_server.server._create_terminal")
37-
def test_assign_no_suffix_when_injection_disabled(self, mock_create, mock_send):
41+
def test_assign_no_suffix_when_injection_disabled(self, mock_create, mock_wait, mock_send):
3842
"""When injection is disabled, assign should send the message unchanged."""
3943
from cli_agent_orchestrator.mcp_server.server import _assign_impl
4044

@@ -51,8 +55,9 @@ def test_assign_no_suffix_when_injection_disabled(self, mock_create, mock_send):
5155

5256
@patch("cli_agent_orchestrator.mcp_server.server.ENABLE_SENDER_ID_INJECTION", True)
5357
@patch("cli_agent_orchestrator.mcp_server.server._send_direct_input")
58+
@patch("cli_agent_orchestrator.mcp_server.server.wait_until_terminal_status", return_value=True)
5459
@patch("cli_agent_orchestrator.mcp_server.server._create_terminal")
55-
def test_assign_sender_id_fallback_unknown(self, mock_create, mock_send):
60+
def test_assign_sender_id_fallback_unknown(self, mock_create, mock_wait, mock_send):
5661
"""When CAO_TERMINAL_ID is not set, suffix should use 'unknown'."""
5762
from cli_agent_orchestrator.mcp_server.server import _assign_impl
5863

@@ -68,8 +73,9 @@ def test_assign_sender_id_fallback_unknown(self, mock_create, mock_send):
6873

6974
@patch("cli_agent_orchestrator.mcp_server.server.ENABLE_SENDER_ID_INJECTION", True)
7075
@patch("cli_agent_orchestrator.mcp_server.server._send_direct_input")
76+
@patch("cli_agent_orchestrator.mcp_server.server.wait_until_terminal_status", return_value=True)
7177
@patch("cli_agent_orchestrator.mcp_server.server._create_terminal")
72-
def test_assign_suffix_is_appended_not_prepended(self, mock_create, mock_send):
78+
def test_assign_suffix_is_appended_not_prepended(self, mock_create, mock_wait, mock_send):
7379
"""The sender ID should be a suffix, not a prefix."""
7480
from cli_agent_orchestrator.mcp_server.server import _assign_impl
7581

test/providers/test_claude_code_unit.py

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,21 @@ def test_initialize_success(self, mock_tmux, mock_wait_status, mock_wait_shell,
2525
"""Test successful initialization."""
2626
mock_wait_shell.return_value = True
2727
mock_wait_status.return_value = True
28-
# _handle_startup_prompts needs get_history to return a string
29-
mock_tmux.get_history.return_value = "Welcome to Claude Code v2.0"
28+
# First call is the pre-launch snapshot, subsequent calls return Claude output
29+
mock_tmux.get_history.side_effect = [
30+
"",
31+
"Welcome to Claude Code v2.0",
32+
"Welcome to Claude Code v2.0",
33+
]
3034

3135
provider = ClaudeCodeProvider("test123", "test-session", "window-0")
32-
result = provider.initialize()
36+
with patch.object(provider, "get_status", return_value=TerminalStatus.IDLE):
37+
result = provider.initialize()
3338

3439
assert result is True
3540
assert provider._initialized is True
3641
mock_wait_shell.assert_called_once()
3742
mock_tmux.send_keys.assert_called_once()
38-
mock_wait_status.assert_called_once()
3943

4044
@patch("cli_agent_orchestrator.providers.claude_code.wait_for_shell")
4145
@patch("cli_agent_orchestrator.providers.claude_code.tmux_client")
@@ -53,15 +57,21 @@ def test_initialize_shell_timeout(self, mock_tmux, mock_wait_shell):
5357
@patch("cli_agent_orchestrator.providers.claude_code.wait_until_status")
5458
@patch("cli_agent_orchestrator.providers.claude_code.tmux_client")
5559
def test_initialize_timeout(self, mock_tmux, mock_wait_status, mock_wait_shell, _):
56-
"""Test initialization timeout."""
60+
"""Test initialization timeout when no Claude markers appear."""
5761
mock_wait_shell.return_value = True
5862
mock_wait_status.return_value = False
59-
mock_tmux.get_history.return_value = "Welcome to Claude Code v2.0"
63+
# Snapshot and loop return the same content → no new Claude markers
64+
mock_tmux.get_history.return_value = "some shell output"
6065

6166
provider = ClaudeCodeProvider("test123", "test-session", "window-0")
6267

63-
with pytest.raises(TimeoutError, match="Claude Code initialization timed out"):
64-
provider.initialize()
68+
with (
69+
patch.object(provider, "_handle_startup_prompts"),
70+
patch("cli_agent_orchestrator.providers.claude_code.time.time", side_effect=[0, 31]),
71+
patch("cli_agent_orchestrator.providers.claude_code.time.sleep"),
72+
):
73+
with pytest.raises(TimeoutError, match="Claude Code initialization timed out"):
74+
provider.initialize()
6575

6676
@_PATCH_SETTINGS
6777
@patch("cli_agent_orchestrator.providers.claude_code.load_agent_profile")
@@ -74,15 +84,20 @@ def test_initialize_with_agent_profile(
7484
"""Test initialization with agent profile."""
7585
mock_wait_shell.return_value = True
7686
mock_wait_status.return_value = True
77-
mock_tmux.get_history.return_value = "Welcome to Claude Code v2.0"
87+
mock_tmux.get_history.side_effect = [
88+
"",
89+
"Welcome to Claude Code v2.0",
90+
"Welcome to Claude Code v2.0",
91+
]
7892
mock_profile = MagicMock()
7993
mock_profile.model = None
8094
mock_profile.system_prompt = "Test system prompt"
8195
mock_profile.mcpServers = None
8296
mock_load.return_value = mock_profile
8397

8498
provider = ClaudeCodeProvider("test123", "test-session", "window-0", "test-agent")
85-
result = provider.initialize()
99+
with patch.object(provider, "get_status", return_value=TerminalStatus.IDLE):
100+
result = provider.initialize()
86101

87102
assert result is True
88103
mock_load.assert_called_once_with("test-agent")
@@ -112,15 +127,20 @@ def test_initialize_with_mcp_servers(
112127
"""Test initialization with MCP servers in profile."""
113128
mock_wait_shell.return_value = True
114129
mock_wait_status.return_value = True
115-
mock_tmux.get_history.return_value = "Welcome to Claude Code v2.0"
130+
mock_tmux.get_history.side_effect = [
131+
"",
132+
"Welcome to Claude Code v2.0",
133+
"Welcome to Claude Code v2.0",
134+
]
116135
mock_profile = MagicMock()
117136
mock_profile.model = None
118137
mock_profile.system_prompt = None
119138
mock_profile.mcpServers = {"server1": {"command": "test", "args": ["--flag"]}}
120139
mock_load.return_value = mock_profile
121140

122141
provider = ClaudeCodeProvider("test123", "test-session", "window-0", "test-agent")
123-
result = provider.initialize()
142+
with patch.object(provider, "get_status", return_value=TerminalStatus.IDLE):
143+
result = provider.initialize()
124144

125145
assert result is True
126146

@@ -132,10 +152,15 @@ def test_initialize_sends_claude_command(self, mock_tmux, mock_wait_status, mock
132152
"""Test that initialize sends the 'claude' command to tmux."""
133153
mock_wait_shell.return_value = True
134154
mock_wait_status.return_value = True
135-
mock_tmux.get_history.return_value = "Welcome to Claude Code v2.0"
155+
mock_tmux.get_history.side_effect = [
156+
"",
157+
"Welcome to Claude Code v2.0",
158+
"Welcome to Claude Code v2.0",
159+
]
136160

137161
provider = ClaudeCodeProvider("test123", "test-session", "window-0")
138-
provider.initialize()
162+
with patch.object(provider, "get_status", return_value=TerminalStatus.IDLE):
163+
provider.initialize()
139164

140165
call_args = mock_tmux.send_keys.call_args
141166
assert call_args[0][0] == "test-session"
@@ -781,7 +806,8 @@ def test_initialize_calls_handle_startup_prompts(
781806
"""Test that initialize calls _handle_startup_prompts."""
782807
mock_wait_shell.return_value = True
783808
mock_wait_status.return_value = True
784-
mock_tmux.get_history.return_value = "❯ 1. Yes, I trust this folder\n 2. No"
809+
trust_output = "❯ 1. Yes, I trust this folder\n 2. No"
810+
mock_tmux.get_history.side_effect = ["", trust_output, trust_output]
785811
mock_session = MagicMock()
786812
mock_window = MagicMock()
787813
mock_pane = MagicMock()
@@ -790,7 +816,8 @@ def test_initialize_calls_handle_startup_prompts(
790816
mock_window.active_pane = mock_pane
791817

792818
provider = ClaudeCodeProvider("test123", "test-session", "window-0")
793-
result = provider.initialize()
819+
with patch.object(provider, "get_status", return_value=TerminalStatus.IDLE):
820+
result = provider.initialize()
794821

795822
assert result is True
796823
mock_pane.send_keys.assert_called_with("", enter=True)

0 commit comments

Comments
 (0)