diff --git a/src/agent_box/agents/claude_code.py b/src/agent_box/agents/claude_code.py index 46b8f81..124331e 100644 --- a/src/agent_box/agents/claude_code.py +++ b/src/agent_box/agents/claude_code.py @@ -582,6 +582,18 @@ async def run(self, prompt: str, user_id: str = "", channel: str = "") -> AsyncI data={"file_path": fp}, ) elif isinstance(block, ToolUseBlock): + # ExitPlanMode carries the plan text in its input — surface + # the full plan so the user can review it on the IM channel, + # instead of the generic "⚙️ ExitPlanMode" one-liner. + if block.name == "ExitPlanMode": + plan = (block.input or {}).get("plan") + if isinstance(plan, str) and plan.strip(): + yield OutgoingMessage( + text=plan.strip(), user_id=user_id, channel=channel, + type=MessageType.text, + data={"id": block.id, "name": block.name, "input": block.input}, + ) + continue # Brief one-liner so the user knows something is happening. summary = _format_tool_summary(block, prefixes=_path_prefixes) yield OutgoingMessage( @@ -689,6 +701,15 @@ async def _recover_from_context_limit( data={"file_path": fp}, ) elif isinstance(block, ToolUseBlock): + if block.name == "ExitPlanMode": + plan = (block.input or {}).get("plan") + if isinstance(plan, str) and plan.strip(): + yield OutgoingMessage( + text=plan.strip(), user_id=user_id, channel=channel, + type=MessageType.text, + data={"id": block.id, "name": block.name, "input": block.input}, + ) + continue summary = _format_tool_summary(block, prefixes=_path_prefixes) yield OutgoingMessage( text=summary, user_id=user_id, channel=channel, type=MessageType.text, diff --git a/tests/test_agents.py b/tests/test_agents.py index c9d0d9c..e29b3fe 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -386,6 +386,115 @@ async def fake_receive(): assert agent._pending_ask is None +# ── ExitPlanMode plan surfacing ── + + +@pytest.mark.anyio +async def test_exit_plan_mode_surfaces_plan(sample_project: ProjectInfo): + """ExitPlanMode with a `plan` field should yield the plan text to the channel.""" + from claude_agent_sdk import AssistantMessage, ResultMessage, ToolUseBlock + + mock_client = AsyncMock() + mock_client.query = AsyncMock() + + plan_body = "# My Plan\n\n## Steps\n1. Do thing A\n2. Do thing B" + + async def fake_receive(): + yield AssistantMessage( + content=[ + ToolUseBlock( + id="toolu_plan", + name="ExitPlanMode", + input={"plan": plan_body}, + ), + ], + model="test", + ) + yield ResultMessage( + subtype="result", is_error=False, duration_ms=100, duration_api_ms=90, + num_turns=1, total_cost_usd=0.01, usage=None, session_id="s1", + ) + + mock_client.receive_response = fake_receive + + agent = ClaudeCodeAgent(sample_project) + agent._client = mock_client + msgs = [m async for m in agent.run("implement feature")] + + texts = [m.text for m in msgs if m.type.value == "text"] + assert any("My Plan" in t and "Do thing A" in t for t in texts) + # Should not fall back to the generic tool summary + assert not any(t == "⚙️ ExitPlanMode" for t in texts) + + +@pytest.mark.anyio +async def test_exit_plan_mode_without_plan_falls_back_to_summary(sample_project: ProjectInfo): + """ExitPlanMode without a plan field should yield the generic tool summary.""" + from claude_agent_sdk import AssistantMessage, ResultMessage, ToolUseBlock + + mock_client = AsyncMock() + mock_client.query = AsyncMock() + + async def fake_receive(): + yield AssistantMessage( + content=[ + ToolUseBlock( + id="toolu_plan2", + name="ExitPlanMode", + input={"allowedPrompts": [{"tool": "Bash", "prompt": "run tests"}]}, + ), + ], + model="test", + ) + yield ResultMessage( + subtype="result", is_error=False, duration_ms=100, duration_api_ms=90, + num_turns=1, total_cost_usd=0.01, usage=None, session_id="s2", + ) + + mock_client.receive_response = fake_receive + + agent = ClaudeCodeAgent(sample_project) + agent._client = mock_client + msgs = [m async for m in agent.run("implement feature")] + + texts = [m.text for m in msgs if m.type.value == "text"] + assert "⚙️ ExitPlanMode" in texts + + +@pytest.mark.anyio +async def test_exit_plan_mode_empty_plan_falls_back_to_summary(sample_project: ProjectInfo): + """ExitPlanMode with an empty/whitespace plan should fall back to summary.""" + from claude_agent_sdk import AssistantMessage, ResultMessage, ToolUseBlock + + mock_client = AsyncMock() + mock_client.query = AsyncMock() + + async def fake_receive(): + yield AssistantMessage( + content=[ + ToolUseBlock( + id="toolu_plan3", + name="ExitPlanMode", + input={"plan": " "}, + ), + ], + model="test", + ) + yield ResultMessage( + subtype="result", is_error=False, duration_ms=100, duration_api_ms=90, + num_turns=1, total_cost_usd=0.01, usage=None, session_id="s3", + ) + + mock_client.receive_response = fake_receive + + agent = ClaudeCodeAgent(sample_project) + agent._client = mock_client + msgs = [m async for m in agent.run("implement feature")] + + texts = [m.text for m in msgs if m.type.value == "text"] + assert "⚙️ ExitPlanMode" in texts + + # ── Tool summary formatting ──