Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/agent_box/agents/claude_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
109 changes: 109 additions & 0 deletions tests/test_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──


Expand Down
Loading