feat(command): add /history command to show recent session messages

Adds /history [n] to display the last N user/assistant messages from
the current session (default 10, max 50).

- Tool and system messages are filtered out for readability
- Long messages are truncated to 200 characters with an ellipsis
- Multimodal content (image blocks) is collapsed to its text parts
- Invalid count argument returns a usage hint
- /history n uses prefix routing; /history uses exact routing

Also registers /history in build_help_text().
This commit is contained in:
Leo fu 2026-04-26 19:11:13 -04:00 committed by Xubin Ren
parent e72c415473
commit 599e25dfbf
2 changed files with 115 additions and 0 deletions

View File

@ -310,6 +310,66 @@ async def cmd_dream_restore(ctx: CommandContext) -> OutboundMessage:
) )
_HISTORY_DEFAULT_COUNT = 10
_HISTORY_MAX_COUNT = 50
_HISTORY_MAX_CONTENT_CHARS = 200
def _format_history_message(msg: dict) -> str | None:
"""Format a single history message for display. Returns None to skip."""
role = msg.get("role")
if role not in ("user", "assistant"):
return None
content = msg.get("content") or ""
if isinstance(content, list):
parts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"]
content = " ".join(parts)
content = str(content).strip()
if not content:
return None
if len(content) > _HISTORY_MAX_CONTENT_CHARS:
content = content[:_HISTORY_MAX_CONTENT_CHARS] + ""
label = "👤 You" if role == "user" else "🤖 Bot"
return f"{label}: {content}"
async def cmd_history(ctx: CommandContext) -> OutboundMessage:
"""Show the last N messages of the current session (default 10, max 50).
Usage: /history [count]
"""
count = _HISTORY_DEFAULT_COUNT
if ctx.args.strip():
try:
count = max(1, min(int(ctx.args.strip()), _HISTORY_MAX_COUNT))
except ValueError:
return OutboundMessage(
channel=ctx.msg.channel, chat_id=ctx.msg.chat_id,
content="Usage: /history [count] — e.g. /history 5 (default: 10, max: 50)",
metadata=dict(ctx.msg.metadata or {}),
)
session = ctx.session or ctx.loop.sessions.get_or_create(ctx.key)
history = session.get_history(max_messages=0)
visible = [_format_history_message(m) for m in history]
visible = [m for m in visible if m is not None]
recent = visible[-count:]
if not recent:
return OutboundMessage(
channel=ctx.msg.channel, chat_id=ctx.msg.chat_id,
content="No conversation history yet.",
metadata=dict(ctx.msg.metadata or {}),
)
header = f"Last {len(recent)} message(s):\n"
return OutboundMessage(
channel=ctx.msg.channel, chat_id=ctx.msg.chat_id,
content=header + "\n".join(recent),
metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"},
)
async def cmd_help(ctx: CommandContext) -> OutboundMessage: async def cmd_help(ctx: CommandContext) -> OutboundMessage:
"""Return available slash commands.""" """Return available slash commands."""
return OutboundMessage( return OutboundMessage(
@ -328,6 +388,7 @@ def build_help_text() -> str:
"/stop — Stop the current task", "/stop — Stop the current task",
"/restart — Restart the bot", "/restart — Restart the bot",
"/status — Show bot status", "/status — Show bot status",
"/history [n] — Show the last N conversation messages (default 10)",
"/dream — Manually trigger Dream consolidation", "/dream — Manually trigger Dream consolidation",
"/dream-log — Show what the last Dream changed", "/dream-log — Show what the last Dream changed",
"/dream-restore — Revert memory to a previous state", "/dream-restore — Revert memory to a previous state",
@ -343,6 +404,8 @@ def register_builtin_commands(router: CommandRouter) -> None:
router.priority("/status", cmd_status) router.priority("/status", cmd_status)
router.exact("/new", cmd_new) router.exact("/new", cmd_new)
router.exact("/status", cmd_status) router.exact("/status", cmd_status)
router.exact("/history", cmd_history)
router.prefix("/history ", cmd_history)
router.exact("/dream", cmd_dream) router.exact("/dream", cmd_dream)
router.exact("/dream-log", cmd_dream_log) router.exact("/dream-log", cmd_dream_log)
router.prefix("/dream-log ", cmd_dream_log) router.prefix("/dream-log ", cmd_dream_log)

View File

@ -243,6 +243,58 @@ class TestRestartCommand:
assert "Context: 1k/65k (1% of input budget)" in response.content assert "Context: 1k/65k (1% of input budget)" in response.content
assert "Tasks: 0 active" in response.content assert "Tasks: 0 active" in response.content
@pytest.mark.asyncio
async def test_history_shows_recent_messages(self):
loop, _bus = _make_loop()
session = MagicMock()
session.get_history.return_value = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there!"},
{"role": "tool", "content": "tool result"}, # should be filtered out
{"role": "user", "content": "How are you?"},
{"role": "assistant", "content": "I am doing well."},
]
loop.sessions.get_or_create.return_value = session
msg = InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/history")
response = await loop._process_message(msg)
assert response is not None
assert "👤 You: Hello" in response.content
assert "🤖 Bot: Hi there!" in response.content
assert "tool result" not in response.content # tool messages filtered
assert response.metadata == {"render_as": "text"}
@pytest.mark.asyncio
async def test_history_respects_count_argument(self):
loop, _bus = _make_loop()
session = MagicMock()
session.get_history.return_value = [
{"role": "user", "content": f"message {i}"} for i in range(20)
]
loop.sessions.get_or_create.return_value = session
msg = InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/history 3")
response = await loop._process_message(msg)
assert response is not None
assert "Last 3 message(s)" in response.content
assert "message 19" in response.content # most recent
assert "message 0" not in response.content # too old
@pytest.mark.asyncio
async def test_history_empty_session(self):
loop, _bus = _make_loop()
session = MagicMock()
session.get_history.return_value = []
loop.sessions.get_or_create.return_value = session
msg = InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/history")
response = await loop._process_message(msg)
assert response is not None
assert "No conversation history yet." in response.content
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_process_direct_preserves_render_metadata(self): async def test_process_direct_preserves_render_metadata(self):
loop, _bus = _make_loop() loop, _bus = _make_loop()