diff --git a/opencane/agent/loop.py b/opencane/agent/loop.py index ad9c8dd277b..96e8595d819 100644 --- a/opencane/agent/loop.py +++ b/opencane/agent/loop.py @@ -544,6 +544,7 @@ def _status_response(self, msg: InboundMessage, session: Session) -> OutboundMes channel=msg.channel, chat_id=msg.chat_id, content=self._build_status_content(session), + metadata={"render_as": "text"}, ) async def _run_agent_loop( @@ -691,6 +692,10 @@ async def run(self) -> None: self.bus.consume_inbound(), timeout=1.0 ) + if msg.content.strip().lower() == "/status": + session = self.sessions.get_or_create(msg.session_key) + await self.bus.publish_outbound(self._status_response(msg, session)) + continue try: response = await self._process_message(msg) if response: @@ -795,8 +800,17 @@ async def _process_message( content="New session started. Memory archived.", ) if cmd == "/help": - return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, - content="🦯 OpenCane commands:\n/new — Start a new conversation\n/status — Show runtime status\n/help — Show available commands") + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=( + "🦯 OpenCane commands:\n" + "/new — Start a new conversation\n" + "/status — Show runtime status\n" + "/help — Show available commands" + ), + metadata={"render_as": "text"}, + ) if cmd == "/status": return self._status_response(msg, session) diff --git a/opencane/cli/commands.py b/opencane/cli/commands.py index f2710d71fb9..ba580fb79f3 100644 --- a/opencane/cli/commands.py +++ b/opencane/cli/commands.py @@ -103,10 +103,23 @@ def _init_prompt_session() -> None: ) -def _print_agent_response(response: str, render_markdown: bool) -> None: +def _response_renderable(content: str, render_markdown: bool, metadata: dict | None = None): + """Render command-style output as plain text even when markdown is enabled.""" + if not render_markdown: + return Text(content) + if (metadata or {}).get("render_as") == "text": + return Text(content) + return Markdown(content) + + +def _print_agent_response( + response: str, + render_markdown: bool, + metadata: dict | None = None, +) -> None: """Render assistant response with consistent terminal styling.""" content = response or "" - body = Markdown(content) if render_markdown else Text(content) + body = _response_renderable(content, render_markdown, metadata) console.print() console.print(f"[cyan]{__logo__} opencane[/cyan]") console.print(body) @@ -896,7 +909,11 @@ async def run_once(): try: with _thinking_ctx(): response = await agent_loop.process_direct(message, session_id) - _print_agent_response(response.content if response else "", render_markdown=markdown) + _print_agent_response( + response.content if response else "", + render_markdown=markdown, + metadata=response.metadata if response else None, + ) finally: await agent_loop.close_mcp() if lifelog_service: @@ -932,7 +949,11 @@ async def run_interactive(): with _thinking_ctx(): response = await agent_loop.process_direct(user_input, session_id) - _print_agent_response(response.content if response else "", render_markdown=markdown) + _print_agent_response( + response.content if response else "", + render_markdown=markdown, + metadata=response.metadata if response else None, + ) except KeyboardInterrupt: _restore_terminal() console.print("\nGoodbye!") diff --git a/tests/test_agent_loop_status_command.py b/tests/test_agent_loop_status_command.py index ae4c52fe909..24b8c1b3257 100644 --- a/tests/test_agent_loop_status_command.py +++ b/tests/test_agent_loop_status_command.py @@ -1,8 +1,10 @@ from __future__ import annotations +import asyncio import time from pathlib import Path from typing import Any +from unittest.mock import AsyncMock import pytest @@ -85,6 +87,7 @@ async def test_status_command_returns_runtime_snapshot_without_provider_call( assert "Subagents: 0 active" in response.content assert "Queue: 0 pending" in response.content assert "Uptime: 2m 5s" in response.content + assert response.metadata == {"render_as": "text"} assert provider.calls == 0 @@ -101,6 +104,36 @@ async def test_help_command_mentions_status(tmp_path: Path) -> None: ) assert response is not None assert "/status" in response.content + assert response.metadata == {"render_as": "text"} + + +@pytest.mark.asyncio +async def test_run_intercepts_status_before_main_processing(tmp_path: Path) -> None: + bus = MessageBus() + loop = AgentLoop( + bus=bus, + provider=_CountingProvider(), + workspace=tmp_path, + ) + + mocked_process = AsyncMock() + loop._process_message = mocked_process # type: ignore[method-assign] + + await bus.publish_inbound( + InboundMessage(channel="cli", sender_id="u1", chat_id="chat-status", content="/status") + ) + + run_task = asyncio.create_task(loop.run()) + try: + outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=1.5) + assert "OpenCane v" in outbound.content + assert outbound.metadata == {"render_as": "text"} + mocked_process.assert_not_awaited() + finally: + loop.stop() + run_task.cancel() + with pytest.raises(asyncio.CancelledError): + await run_task @pytest.mark.asyncio diff --git a/tests/test_cli_input.py b/tests/test_cli_input.py index 99f36d6d6b3..3ba89cff8d6 100644 --- a/tests/test_cli_input.py +++ b/tests/test_cli_input.py @@ -56,3 +56,17 @@ def test_init_prompt_session_creates_session(): _, kwargs = mock_session_cls.call_args assert kwargs["multiline"] is False assert kwargs["enable_open_in_editor"] is False + + +def test_response_renderable_uses_text_when_render_as_text_metadata() -> None: + renderable = commands._response_renderable( + "line1\nline2", + render_markdown=True, + metadata={"render_as": "text"}, + ) + assert renderable.__class__.__name__ == "Text" + + +def test_response_renderable_keeps_markdown_when_no_text_metadata() -> None: + renderable = commands._response_renderable("**bold**", render_markdown=True) + assert renderable.__class__.__name__ == "Markdown"