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
18 changes: 16 additions & 2 deletions opencane/agent/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Expand Down
29 changes: 25 additions & 4 deletions opencane/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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!")
Expand Down
33 changes: 33 additions & 0 deletions tests/test_agent_loop_status_command.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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


Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions tests/test_cli_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading