mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-04 00:35:58 +00:00
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:
parent
e72c415473
commit
599e25dfbf
@ -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)
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user