feat(tui): clean transcript style, right-click context menus, rate limits & dual copy

- Add clean line-by-line transcript style toggle ('b' key / /clean / /boxed)
- Implement right-click context menus for chat list, fleet agent panel, and transcript
- Add cooldown mode lock bypass (2-second double-confirm force sync)
- Implement dual copy support: clean text (strip reply metadata) vs full context
- Add clickable [📋 Copy] and [📑+ Context] buttons to message headers
- Add unit test suites for transcript cleaning, context menus, rate limits, and copy actions
This commit is contained in:
operator
2026-10-06 07:55:27 +00:00
parent f2640397ed
commit b7e45010c3
14 changed files with 8778 additions and 0 deletions
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""
test_agent_ranking.py — Unit test suite for Fleet Agent interaction ranking & sorting:
1. Agent interaction recording on true input / chat [insert].
2. Fleet agent sorting/ranking by last interaction descending.
3. Stable fallback ordering for un-interacted agents.
4. Persistence and seeding of interaction timestamps.
5. format_recency helper accuracy.
"""
import unittest
from unittest.mock import MagicMock, patch
import importlib.util
from pathlib import Path
import time
import tempfile
import json
REPO_ROOT = Path("/home/super/Projects/NetVM")
TUI_PATH = REPO_ROOT / "bin" / "muse-tui.py"
spec = importlib.util.spec_from_file_location("muse_tui", str(TUI_PATH))
muse_mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(muse_mod)
class TestAgentRanking(unittest.TestCase):
"""Test suite covering fleet agent interaction sorting & recency tracking."""
def setUp(self):
self.mock_stdscr = MagicMock()
self.mock_stdscr.getmaxyx.return_value = (30, 100)
self.tui = muse_mod.MuseTUI(self.mock_stdscr, initial_mode="muse", initial_node="muse")
self.tui.safe_addstr = MagicMock()
self.tui.data.history_cache = {}
self.tui.data.read_msg_counts = {}
def test_format_recency(self):
"""Verify format_recency produces compact, accurate recency strings."""
now = time.time()
self.assertEqual(muse_mod.format_recency(0.0), "never")
self.assertEqual(muse_mod.format_recency(None), "never")
self.assertEqual(muse_mod.format_recency(now - 10), "10s ago")
self.assertEqual(muse_mod.format_recency(now - 120), "2m ago")
self.assertEqual(muse_mod.format_recency(now - 7200), "2h ago")
self.assertEqual(muse_mod.format_recency(now - 90000), "1d ago")
def test_sort_nodes_by_interaction(self):
"""Verify sort_nodes ranks nodes by last interaction timestamp descending."""
data = self.tui.data
now = time.time()
# Set specific timestamps
data.agent_interactions["muse"] = now - 500
data.agent_interactions["pip"] = now - 100
data.agent_interactions["646"] = now - 10 # Most recent
data.agent_interactions["opm"] = 0.0 # Never
data.agent_interactions["def"] = 0.0 # Never
data.sort_nodes()
# Most recent first: 646, then pip, then muse
self.assertEqual(data.nodes[0], "646")
self.assertEqual(data.nodes[1], "pip")
self.assertEqual(data.nodes[2], "muse")
# Un-interacted agents should be at the end, retaining baseline order
self.assertIn("opm", data.nodes[3:])
self.assertIn("def", data.nodes[3:])
def test_record_interaction_promotes_node_to_top(self):
"""Verify record_interaction immediately updates timestamp and promotes node to rank 1."""
data = self.tui.data
# Ensure 'pip' is not at index 0 initially
data.record_interaction("646")
self.assertEqual(data.nodes[0], "646")
# Now operator directly interacts with 'pip'
t0 = time.time()
data.record_interaction("pip")
self.assertEqual(data.nodes[0], "pip", "'pip' must be promoted to rank 1 (index 0)")
self.assertEqual(data.nodes[1], "646", "'646' must be rank 2")
self.assertGreaterEqual(data.agent_interactions["pip"], t0)
def test_chat_message_submission_triggers_ranking(self):
"""Verify submitting chat message in INSERT mode promotes the active node."""
self.tui.editor_mode = "INSERT"
self.tui.data.active_node = "def"
self.tui.data.active_thread_id = "test-session-123"
self.tui.data.active_thread_title = "Def Task Session"
# Mock async send to avoid subprocess
with patch.object(self.tui, "_async_send_message"):
self.tui._execute_input_line("Deploy updated service configuration")
# 'def' was chatted with directly; it must now be rank 1 at index 0
self.assertEqual(self.tui.data.nodes[0], "def", "Chat message send must promote target node to top of list")
def test_persistence_of_interactions(self):
"""Verify interaction records persist and load from json correctly."""
with tempfile.TemporaryDirectory() as tmpdir:
fake_int_file = Path(tmpdir) / "agent_interactions.json"
now = time.time()
fake_int_file.write_text(json.dumps({"opm": now - 50, "dev": now - 10}))
with patch("pathlib.Path.home", return_value=Path(tmpdir)), \
patch.object(muse_mod.Path, "home", return_value=Path(tmpdir)):
# Adjust path to match ~/.config/muse-cli/
cfg_dir = Path(tmpdir) / ".config" / "muse-cli"
cfg_dir.mkdir(parents=True, exist_ok=True)
(cfg_dir / "agent_interactions.json").write_text(json.dumps({"opm": now - 50, "dev": now - 10}))
mgr = muse_mod.FleetDataManager()
mgr.stop() # Stop background poller
self.assertAlmostEqual(mgr.agent_interactions.get("dev", 0.0), now - 10, delta=1.0)
self.assertAlmostEqual(mgr.agent_interactions.get("opm", 0.0), now - 50, delta=1.0)
self.assertEqual(mgr.nodes[0], "dev", "Persisted recent node 'dev' must be rank 1")
self.assertEqual(mgr.nodes[1], "opm", "'opm' must be rank 2")
if __name__ == "__main__":
unittest.main()
+87
View File
@@ -0,0 +1,87 @@
import unittest
from unittest.mock import MagicMock, patch
import importlib.util
import sys
from pathlib import Path
REPO_ROOT = Path("/home/super/Projects/NetVM")
sys.path.insert(0, str(REPO_ROOT / "bin"))
class TestBlockedApprovals(unittest.TestCase):
@classmethod
def setUpClass(cls):
spec = importlib.util.spec_from_file_location("muse_tui", str(REPO_ROOT / "bin" / "muse-tui.py"))
cls.muse_tui = importlib.util.module_from_spec(spec)
spec.loader.exec_module(cls.muse_tui)
def setUp(self):
self.mock_stdscr = MagicMock()
self.mock_stdscr.getmaxyx.return_value = (40, 120)
self.app = self.muse_tui.MuseTUI(self.mock_stdscr, initial_mode="muse", initial_node="muse")
self.app.data = MagicMock()
self.app.data.nodes = ["muse", "pip", "646", "opm", "def", "dev"]
self.app.data.active_node = "muse"
self.app.data.active_thread_id = "thread-1"
self.app.data.active_thread_title = "Main Chat"
self.app.data.lock = MagicMock()
self.app.data.lock.__enter__.return_value = None
self.app.data.lock.__exit__.return_value = None
def test_slash_blocked_opens_approvals_modal(self):
self.app.data.approvals_cache = [
{"node": "opm", "status": "PENDING", "title": "Review task", "is_trusted": True},
{"node": "646", "status": "PENDING", "title": "SSH connect", "is_trusted": False},
]
self.app._execute_input_line("/blocked")
self.assertEqual(self.app.modal, "approvals")
self.assertEqual(self.app.approvals_sel_idx, 0)
self.app._execute_input_line("/Blocked")
self.assertEqual(self.app.modal, "approvals")
self.app._execute_input_line("/approvals")
self.assertEqual(self.app.modal, "approvals")
def test_approvals_modal_navigation_and_targeting(self):
self.app.modal = "approvals"
self.app.approvals_sel_idx = 0
self.app.data.approvals_cache = [
{"node": "muse", "status": "PENDING", "title": "Task 1"},
{"node": "opm", "status": "PENDING", "title": "Task 2"},
]
# Press 'j' (down)
handled = self.app._handle_modal_key(ord('j'))
self.assertTrue(handled)
self.assertEqual(self.app.approvals_sel_idx, 1)
# Press '1' (allow) -> should target 'opm' (selected row), not 'muse' (active_node)
with patch.object(self.app, "_async_resolve_approval") as mock_resolve:
handled = self.app._handle_modal_key(ord('1'))
self.assertTrue(handled)
self.assertIsNone(self.app.modal)
mock_resolve.assert_called_once_with("allow", "opm")
def test_approvals_modal_navigation_k(self):
self.app.modal = "approvals"
self.app.approvals_sel_idx = 1
self.app.data.approvals_cache = [
{"node": "muse", "status": "PENDING", "title": "Task 1"},
{"node": "opm", "status": "PENDING", "title": "Task 2"},
]
# Press 'k' (up)
handled = self.app._handle_modal_key(ord('k'))
self.assertTrue(handled)
self.assertEqual(self.app.approvals_sel_idx, 0)
# Press '2' (always) -> should target 'muse'
with patch.object(self.app, "_async_resolve_approval") as mock_resolve:
handled = self.app._handle_modal_key(ord('2'))
self.assertTrue(handled)
mock_resolve.assert_called_once_with("always", "muse")
if __name__ == "__main__":
unittest.main()
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""
test_clean_transcript.py — Unit and integration tests for clean line-by-line transcript view:
1. Default display style is 'clean' (no box borders, no bottom rules, no vertical pipe bars).
2. Clean mode renders bullet header indicators (● AGENT, ▸ YOU) and 2-space indented body lines.
3. Boxed mode retains classic ASCII/Unicode frames (┌──, │ , └───).
4. Runtime style toggling via 'b' / 'B' hotkey in Normal mode.
5. Slash commands: /clean, /boxed, /view, /compact.
6. Selection and copy extraction (_copy_rendered_line_range) handles clean formatting seamlessly.
7. Settings persistence in ~/.config/muse-cli/tui_settings.json.
"""
import curses
import importlib.util
import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
REPO_ROOT = Path(__file__).resolve().parent.parent
MUSE_TUI_PATH = REPO_ROOT / "bin" / "muse-tui.py"
spec = importlib.util.spec_from_file_location("muse_tui", MUSE_TUI_PATH)
muse_tui = importlib.util.module_from_spec(spec)
sys.modules["muse_tui"] = muse_tui
spec.loader.exec_module(muse_tui)
MuseTUI = muse_tui.MuseTUI
class TestCleanTranscript(unittest.TestCase):
def setUp(self):
self.mock_stdscr = MagicMock()
self.mock_stdscr.getmaxyx.return_value = (30, 100)
self.tui = MuseTUI(self.mock_stdscr, initial_mode="muse", initial_node="muse")
self.tui.safe_addstr = MagicMock()
self.messages = [
{
"role": "assistant",
"text": "Same sweep thread, routine — holding on dev-i04.\nSecond line of response.",
"message_id": "ast-msg-86648",
"seq": 86648,
},
{
"role": "user",
"text": "Roger that, continue monitoring.\n[WO:20261006-1] Active",
"message_id": "usr-msg-d183a734",
"seq": 86649,
},
]
with self.tui.data.lock:
self.tui.data.history_cache[(self.tui.data.active_node, self.tui.data.active_thread_id)] = self.messages
def test_default_style_is_clean(self):
"""Verify that newly initialized TUI defaults to 'clean' transcript style."""
self.assertEqual(self.tui.transcript_style, "clean")
def test_render_transcript_clean_mode(self):
"""In 'clean' mode, lines do NOT contain ┌──, │ , or └───, and use clean bullets."""
self.tui.transcript_style = "clean"
self.tui._transcript_cache_key = None
self.tui._render_transcript(1, 0, 25, 90)
lines = [item[0] for item in self.tui._transcript_cache_lines]
combined = "\n".join(lines)
# Must not contain box border frames
self.assertNotIn("┌──", combined)
self.assertNotIn("└──", combined)
self.assertNotIn("│ ", combined)
# Must contain clean indicators
self.assertTrue(any(l.startswith("● MUSE") for l in lines), "Agent header must start with clean bullet ●")
self.assertTrue(any(l.startswith("▸ YOU") for l in lines), "User header must start with clean prompt ▸")
# Body lines must use 2-space indentation
self.assertTrue(any(l.startswith(" Same sweep thread") for l in lines))
self.assertTrue(any(l.startswith(" Second line") for l in lines))
self.assertTrue(any(l.startswith(" Roger that") for l in lines))
def test_render_transcript_boxed_mode(self):
"""In 'boxed' mode, classic ASCII/Unicode frames (┌──, │ , └───) are rendered."""
self.tui.transcript_style = "boxed"
self.tui._transcript_cache_key = None
self.tui._render_transcript(1, 0, 25, 90)
lines = [item[0] for item in self.tui._transcript_cache_lines]
combined = "\n".join(lines)
self.assertIn("┌── [MUSE / MUSE AGENT]", combined)
self.assertIn("┌── [YOU / OPERATOR]", combined)
self.assertIn("│ Same sweep thread", combined)
self.assertIn("└──", combined)
def test_toggle_transcript_style(self):
"""toggle_transcript_style switches between clean and boxed, invalidating cache."""
self.tui.transcript_style = "clean"
self.tui._transcript_cache_key = ("some_key",)
self.tui.toggle_transcript_style()
self.assertEqual(self.tui.transcript_style, "boxed")
self.assertIsNone(self.tui._transcript_cache_key)
self.assertIn("Classic boxed", self.tui.toast_msg)
self.tui.toggle_transcript_style()
self.assertEqual(self.tui.transcript_style, "clean")
self.assertIsNone(self.tui._transcript_cache_key)
self.assertIn("Clean line-by-line", self.tui.toast_msg)
def test_normal_mode_hotkey_b_toggles_style(self):
"""Pressing 'b' or 'B' in normal mode toggles transcript style."""
self.tui.editor_mode = "NORMAL"
self.tui.copy_mode = False
self.tui.transcript_style = "clean"
handled = self.tui._handle_key(ord('b'))
self.assertTrue(handled)
self.assertEqual(self.tui.transcript_style, "boxed")
handled = self.tui._handle_key(ord('B'))
self.assertTrue(handled)
self.assertEqual(self.tui.transcript_style, "clean")
def test_slash_commands_clean_and_boxed(self):
"""Slash commands /clean, /boxed, and /view correctly set style."""
self.tui.transcript_style = "clean"
# /boxed
self.tui._execute_input_line("/boxed")
self.assertEqual(self.tui.transcript_style, "boxed")
# /clean
self.tui._execute_input_line("/clean")
self.assertEqual(self.tui.transcript_style, "clean")
# /view box
self.tui._execute_input_line("/view box")
self.assertEqual(self.tui.transcript_style, "boxed")
# /view clean
self.tui._execute_input_line("/view clean")
self.assertEqual(self.tui.transcript_style, "clean")
# /style (toggles)
self.tui._execute_input_line("/style")
self.assertEqual(self.tui.transcript_style, "boxed")
def test_copy_range_in_clean_mode(self):
"""_copy_rendered_line_range strips clean 2-space indents and action buttons."""
self.tui.transcript_style = "clean"
self.tui._transcript_cache_key = None
self.tui._render_transcript(1, 0, 25, 90)
# Total lines in cache
lines = self.tui._transcript_cache_lines
copied = self.tui._copy_rendered_line_range(0, len(lines) - 1)
self.assertNotIn("│", copied)
self.assertNotIn("[📋 Copy]", copied)
self.assertNotIn("[↩ Reply]", copied)
self.assertIn("Same sweep thread", copied)
self.assertIn("Roger that, continue monitoring.", copied)
def test_settings_persistence(self):
"""Style preference is saved to and loaded from JSON configuration."""
with tempfile.TemporaryDirectory() as tmp_dir:
fake_cfg = Path(tmp_dir) / "tui_settings.json"
with patch("pathlib.Path.home", return_value=Path(tmp_dir)):
with patch.object(Path, "mkdir"):
with patch("builtins.open", unittest.mock.mock_open()):
self.tui.set_transcript_style("boxed")
self.assertEqual(self.tui.transcript_style, "boxed")
if __name__ == "__main__":
unittest.main()
+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""
test_context_menus.py — Unit and integration tests for Right-Click function(s)
and Context Action Menus in NetVM/Muse TUI:
1. Right-click on Chat List (sidebar sidechats/threads) opens `chat_context` menu.
2. Right-click on Fleet Agents panel opens `agent_context` menu.
3. Right-click on Transcript message opens `message_context` menu.
4. Chat Context Menu actions (Open, Pin/Unpin, Mark as Read, Copy ID, Copy URL, Reply, Close).
5. Fleet Agent Context Menu actions (Open, Sync, Approvals, Mark All Read, Tmux, Work Order).
6. Message Context Menu actions (Reply, Copy Text, Pin to Prompts, Copy ID).
7. Keyboard navigation (j/k, 1-8, Enter) and mouse click selection inside context menus.
8. Normal mode hotkeys ('x' / 'c') and slash commands (/context chat, /context agent).
"""
import curses
import importlib.util
import os
import sys
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
REPO_ROOT = Path(__file__).resolve().parent.parent
MUSE_TUI_PATH = REPO_ROOT / "bin" / "muse-tui.py"
spec = importlib.util.spec_from_file_location("muse_tui", MUSE_TUI_PATH)
muse_tui = importlib.util.module_from_spec(spec)
sys.modules["muse_tui"] = muse_tui
spec.loader.exec_module(muse_tui)
MuseTUI = muse_tui.MuseTUI
class TestContextMenus(unittest.TestCase):
def setUp(self):
self.mock_stdscr = MagicMock()
self.mock_stdscr.getmaxyx.return_value = (30, 100)
self.tui = MuseTUI(self.mock_stdscr, initial_mode="muse", initial_node="muse")
self.tui.safe_addstr = MagicMock()
# Seed mock threads
self.threads = [
{"session_id": "sess-main-1234", "title": "Main Chat", "is_main": True, "pinned": False},
{"session_id": "sess-side-5678", "title": "Audit Sidechat", "is_main": False, "pinned": True},
{"session_id": "sess-side-9999", "title": "Dev Sidechat", "is_main": False, "pinned": False},
]
with self.tui.data.lock:
self.tui.data.pinned_threads["muse"] = set()
self.tui.data.threads_cache["muse"] = list(self.threads)
self.tui.data.active_thread_id = "sess-main-1234"
self.tui.data.active_thread_title = "Main Chat"
self.tui.data.history_cache[("muse", "sess-main-1234")] = [
{"role": "user", "text": "Hello muse", "seq": 1, "message_id": "mid-user-1"},
{"role": "assistant", "text": "System ready", "seq": 2, "message_id": "mid-agent-2"},
]
self.tui.data.history_cache[("muse", "sess-side-5678")] = [
{"role": "user", "text": "Review log", "seq": 3, "message_id": "mid-user-3"},
]
def test_right_click_on_chat_list_opens_chat_context(self):
"""Right-clicking a thread row in sidebar opens chat_context modal."""
# Screen layout: content_y = 1, visible_agents = 6, divider_y = 8, threads_hdr_y = 9, list_y = 10
# Right click on thread index 1 (sess-side-5678) at my = 11, mx = 10
bstate = curses.BUTTON3_CLICKED
self.tui._handle_mouse(mx=10, my=11, bstate=bstate)
self.assertEqual(self.tui.modal, "chat_context")
self.assertIsNotNone(self.tui.context_chat)
self.assertEqual(self.tui.context_chat["thread"]["session_id"], "sess-side-5678")
self.assertEqual(self.tui.context_chat["thread"]["title"], "Audit Sidechat")
def test_right_click_on_fleet_agent_opens_agent_context(self):
"""Right-clicking an agent row in sidebar opens agent_context modal."""
# Row content_y + 1 = 2 is first agent (index 0 = 'muse')
# Row content_y + 2 = 3 is second agent
bstate = getattr(curses, "BUTTON3_PRESSED", 0x800)
with self.tui.data.lock:
second_agent = self.tui.data.nodes[1] if len(self.tui.data.nodes) > 1 else "muse"
self.tui._handle_mouse(mx=10, my=3, bstate=bstate)
self.assertEqual(self.tui.modal, "agent_context")
self.assertIsNotNone(self.tui.context_agent)
self.assertEqual(self.tui.context_agent["node"], second_agent)
def test_right_click_on_transcript_opens_message_context(self):
"""Right-clicking a message in transcript opens message_context modal."""
# Populate rendered lines in transcript
self.tui._render_transcript(1, 26, 25, 74)
# Click on transcript area mx = 50, my = 5
bstate = curses.BUTTON3_CLICKED
self.tui._handle_mouse(mx=50, my=5, bstate=bstate)
self.assertEqual(self.tui.modal, "message_context")
self.assertIsNotNone(self.tui.context_message)
self.assertIn("text", self.tui.context_message)
def test_chat_context_pin_action(self):
"""Executing 'pin' action in chat_context toggles pin state."""
t = {"session_id": "sess-side-9999", "title": "Dev Sidechat", "pinned": False}
self.tui.context_chat = {"node": "muse", "thread": t, "index": 2}
self.tui.modal = "chat_context"
# Press 'p'
self.tui._handle_modal_key(ord('p'))
self.assertIsNone(self.tui.modal)
self.assertIn("📌 Pinned chat", self.tui.toast_msg)
self.assertIn("sess-side-9999", self.tui.data.pinned_threads.get("muse", set()))
def test_chat_context_mark_read_action(self):
"""Executing 'read' action marks thread as read."""
t = {"session_id": "sess-side-5678", "title": "Audit Sidechat"}
self.tui.context_chat = {"node": "muse", "thread": t, "index": 1}
self.tui.modal = "chat_context"
self.tui._handle_modal_key(ord('m'))
self.assertIsNone(self.tui.modal)
self.assertIn("Marked 'Audit Sidechat' as read", self.tui.toast_msg)
self.assertEqual(self.tui.data.get_unread_count("muse", "sess-side-5678"), 0)
def test_chat_context_copy_id_action(self):
"""Executing 'copy_id' action copies session ID to clipboard."""
t = {"session_id": "sess-side-5678", "title": "Audit Sidechat"}
self.tui.context_chat = {"node": "muse", "thread": t, "index": 1}
self.tui.modal = "chat_context"
mod = sys.modules.get(MuseTUI.__module__, muse_tui)
with patch.object(mod, "copy_to_clipboard") as mock_cp1, \
patch.object(muse_tui, "copy_to_clipboard") as mock_cp2:
self.tui._handle_modal_key(ord('y'))
self.assertTrue(mock_cp1.called or mock_cp2.called)
self.assertIn("Copied session ID", self.tui.toast_msg)
def test_chat_context_reply_action(self):
"""Executing 'reply' action switches to chat and quotes latest message."""
t = {"session_id": "sess-main-1234", "title": "Main Chat"}
self.tui.context_chat = {"node": "muse", "thread": t, "index": 0}
self.tui.modal = "chat_context"
self.tui._handle_modal_key(ord('r'))
self.assertIsNone(self.tui.modal)
self.assertIsNotNone(self.tui.reply_target)
self.assertEqual(self.tui.reply_target["role"], "assistant")
self.assertIn("System ready", self.tui.reply_target["text"])
def test_agent_context_actions(self):
"""Agent context menu hotkeys execute appropriate agent actions."""
self.tui.context_agent = {"node": "muse"}
self.tui.modal = "agent_context"
# Mark all chats on agent as read ('m')
self.tui._handle_modal_key(ord('m'))
self.assertIsNone(self.tui.modal)
self.assertIn("Marked all chats on MUSE as read", self.tui.toast_msg)
# Open chat with agent ('1')
self.tui.context_agent = {"node": "muse"}
self.tui.modal = "agent_context"
self.tui._handle_modal_key(ord('1'))
self.assertIsNone(self.tui.modal)
self.assertIn("Switched to agent", self.tui.toast_msg)
def test_normal_mode_x_opens_chat_context(self):
"""Pressing 'x' in normal mode when focused on sidebar opens chat_context."""
self.tui.editor_mode = "NORMAL"
self.tui.focus_pane = "sidebar"
self.tui.thread_sel_idx = 1
handled = self.tui._handle_key(ord('x'))
self.assertTrue(handled)
self.assertEqual(self.tui.modal, "chat_context")
self.assertEqual(self.tui.context_chat["thread"]["session_id"], "sess-side-5678")
def test_slash_command_context(self):
"""Slash command /context opens chat_context or agent_context."""
self.tui._execute_input_line("/context chat")
self.assertEqual(self.tui.modal, "chat_context")
self.tui.modal = None
self.tui._execute_input_line("/context agent")
self.assertEqual(self.tui.modal, "agent_context")
if __name__ == "__main__":
unittest.main()
+374
View File
@@ -0,0 +1,374 @@
#!/usr/bin/env python3
"""
test_copy_actions.py — Unit and integration tests for:
1. Multi-environment clipboard engine (desktop utilities, OSC 52, internal buffer).
2. Transcript formatting engine (Markdown structured headers, roles, seq, id).
3. UI-first button approach:
- Clickable [📋 Copy All] in thread header.
- Clickable [📋 Copy] button on each message header.
4. Normal mode hotkeys:
- 'y': copy latest/active message to clipboard.
- 'Y': copy entire chat transcript to clipboard.
5. Slash commands:
- '/copy', '/yank', '/cp': copy full transcript.
- '/copy last', '/copy message': copy latest message.
"""
import curses
import importlib.util
import os
import sys
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
REPO_ROOT = Path(__file__).resolve().parent.parent
MUSE_TUI_PATH = REPO_ROOT / "bin" / "muse-tui.py"
spec = importlib.util.spec_from_file_location("muse_tui", MUSE_TUI_PATH)
muse_tui = importlib.util.module_from_spec(spec)
sys.modules["muse_tui"] = muse_tui
spec.loader.exec_module(muse_tui)
MuseTUI = muse_tui.MuseTUI
copy_to_clipboard = muse_tui.copy_to_clipboard
strip_message_metadata = muse_tui.strip_message_metadata
class TestCopyActions(unittest.TestCase):
def setUp(self):
self.mock_stdscr = MagicMock()
self.mock_stdscr.getmaxyx.return_value = (30, 100)
self.tui = MuseTUI(self.mock_stdscr, initial_mode="muse", initial_node="muse")
self.tui.safe_addstr = MagicMock()
# Seed conversation history for testing
self.messages = [
{
"role": "user",
"text": "Hello muse, check system status",
"message_id": "usr-msg-12345678",
"seq": 1,
},
{
"role": "assistant",
"text": "All services nominal.\nCDP port 9222 active.",
"message_id": "ast-msg-87654321",
"seq": 2,
},
{
"role": "user",
"text": "Please summarize logs",
"message_id": "usr-msg-99999999",
"seq": 3,
},
]
self.tui.data.history_cache[("muse", "thread-abc")] = list(self.messages)
self.tui.data.active_node = "muse"
self.tui.data.active_thread_id = "thread-abc"
self.tui.data.active_thread_title = "Diagnostics"
def test_strip_message_metadata(self):
"""Verify strip_message_metadata strips reply headers and unquotes quoted lines."""
raw = (
"> Replying to ASSISTANT (seq:12620):\n"
"> Relevant to the timer question: the loop scout just surfaced 6 unanswered jobs.\n"
"> So part of getting work done is already queued.\n\n"
"yes, proceed with spawning 5 sub agents to assist you ; continue at all costs"
)
expected = (
"Relevant to the timer question: the loop scout just surfaced 6 unanswered jobs.\n"
"So part of getting work done is already queued.\n\n"
"yes, proceed with spawning 5 sub agents to assist you ; continue at all costs"
)
self.assertEqual(strip_message_metadata(raw), expected)
# Message without reply tags remains unchanged
no_tags = "Standard message without reply headers."
self.assertEqual(strip_message_metadata(no_tags), no_tags)
def test_copy_to_clipboard_osc52(self):
"""Verify copy_to_clipboard formats and flushes OSC 52 sequence."""
with patch("sys.stdout.write") as mock_write, patch("sys.stdout.flush"):
ok = copy_to_clipboard("test-copy-payload")
self.assertTrue(ok)
# Verify OSC 52 sequence was written
args = [call.args[0] for call in mock_write.call_args_list]
osc_written = any("\033]52;c;" in a for a in args)
self.assertTrue(osc_written)
def test_get_formatted_transcript_content(self):
"""Verify structured Markdown output contains thread title, roles, seq, id, and text."""
formatted = self.tui.get_formatted_transcript()
self.assertIn("# Chat Transcript: Diagnostics", formatted)
self.assertIn("Node: MUSE | Thread ID: thread-abc | Total Messages: 3", formatted)
self.assertIn("### [YOU / OPERATOR] id:usr-msg-", formatted)
self.assertIn("Hello muse, check system status", formatted)
self.assertIn("### [MUSE / MUSE AGENT] seq:2 id:ast-msg-", formatted)
self.assertIn("All services nominal.", formatted)
self.assertIn("Please summarize logs", formatted)
def test_copy_transcript_to_clipboard(self):
"""Verify copy_transcript_to_clipboard updates internal buffer and sets success toast."""
with patch.object(muse_tui, "copy_to_clipboard", return_value=True):
ok = self.tui.copy_transcript_to_clipboard()
self.assertTrue(ok)
self.assertIn("# Chat Transcript: Diagnostics", self.tui.clipboard_buf)
self.assertIn("Copied full transcript (3 msgs", self.tui.toast_msg)
def test_copy_message_to_clipboard_latest(self):
"""Verify copy_message_to_clipboard defaults to latest message."""
with patch.object(muse_tui, "copy_to_clipboard", return_value=True):
ok = self.tui.copy_message_to_clipboard()
self.assertTrue(ok)
self.assertEqual(self.tui.clipboard_buf, "Please summarize logs")
self.assertIn("Copied clean message to clipboard", self.tui.toast_msg)
def test_copy_message_to_clipboard_with_and_without_context(self):
"""Verify with_context toggles between metadata stripping and raw text."""
raw = "> Replying to ASSISTANT (seq:1):\n> Quoted header\n\nActual response body"
with patch.object(muse_tui, "copy_to_clipboard", return_value=True):
# Clean text (without context)
self.tui.copy_message_to_clipboard(raw, with_context=False)
self.assertEqual(self.tui.clipboard_buf, "Quoted header\n\nActual response body")
self.assertIn("clean message", self.tui.toast_msg)
# Raw text (with context)
self.tui.copy_message_to_clipboard(raw, with_context=True)
self.assertEqual(self.tui.clipboard_buf, raw)
self.assertIn("message with context", self.tui.toast_msg)
def test_copy_message_to_clipboard_explicit(self):
"""Verify copy_message_to_clipboard with explicit message text."""
with patch.object(muse_tui, "copy_to_clipboard", return_value=True):
custom_text = "Specific arbitrary message payload"
ok = self.tui.copy_message_to_clipboard(custom_text)
self.assertTrue(ok)
self.assertEqual(self.tui.clipboard_buf, custom_text)
def test_render_transcript_buttons(self):
"""Verify [📋 Copy All], [📋 Copy] and [📑+ Context] buttons are rendered and registered."""
self.tui._render_transcript(y=1, x=24, h=25, w=76)
# Check [📋 Copy All] bounds registered
self.assertIsNotNone(self.tui._btn_copy_all_bounds)
btn_y, x_start, x_end = self.tui._btn_copy_all_bounds
self.assertEqual(btn_y, 1)
self.assertGreater(x_end, x_start)
# Verify button string was rendered to screen at btn coordinates
calls = [c for c in self.tui.safe_addstr.call_args_list if "[📋 Copy All]" in str(c)]
self.assertTrue(len(calls) > 0, "[📋 Copy All] must be rendered to stdscr")
win_arg, call_y, call_x, call_text, call_attr = calls[0].args
self.assertEqual(call_y, 1)
self.assertEqual(call_x, x_start)
# Check individual message copy buttons registered
self.assertGreater(len(self.tui._msg_copy_buttons), 0)
self.assertGreater(len(self.tui._msg_copy_context_buttons), 0)
for (my, mx1, mx2, raw_text) in self.tui._msg_copy_buttons:
self.assertGreater(mx2, mx1)
self.assertIn(raw_text, [m["text"] for m in self.messages])
for (my, mx1, mx2, raw_text) in self.tui._msg_copy_context_buttons:
self.assertGreater(mx2, mx1)
self.assertIn(raw_text, [m["text"] for m in self.messages])
def test_render_transcript_narrow_screen_retains_copy_button(self):
"""Verify [📋 Copy All] is retained next to message count even with long title & narrow width."""
self.tui.data.active_thread_title = "Extremely Long Comprehensive Diagnostics Thread Title Exceeding Width"
self.tui._render_transcript(y=1, x=10, h=20, w=48)
self.assertIsNotNone(self.tui._btn_copy_all_bounds)
btn_y, x_start, x_end = self.tui._btn_copy_all_bounds
self.assertEqual(btn_y, 1)
self.assertLessEqual(x_end, 10 + 48, "Button must stay within transcript boundary")
calls = [c for c in self.tui.safe_addstr.call_args_list if "[📋 Copy All]" in str(c)]
self.assertTrue(len(calls) > 0)
def test_mouse_click_copy_all(self):
"""Verify clicking [📋 Copy All] copies full transcript to clipboard."""
self.tui._render_transcript(y=1, x=24, h=25, w=76)
btn_y, x_start, x_end = self.tui._btn_copy_all_bounds
click_x = (x_start + x_end) // 2
with patch.object(self.tui, "copy_transcript_to_clipboard") as mock_copy_all:
handled = self.tui._handle_mouse(click_x, btn_y, curses.BUTTON1_CLICKED)
self.assertTrue(handled)
mock_copy_all.assert_called_once()
def test_mouse_click_message_copy(self):
"""Verify clicking an individual [📋 Copy] button copies that specific message without context."""
self.tui._render_transcript(y=1, x=24, h=25, w=76)
self.assertGreater(len(self.tui._msg_copy_buttons), 0)
first_btn = self.tui._msg_copy_buttons[0]
msg_y, x_start, x_end, raw_text = first_btn
click_x = (x_start + x_end) // 2
with patch.object(self.tui, "copy_message_to_clipboard") as mock_copy_msg:
handled = self.tui._handle_mouse(click_x, msg_y, curses.BUTTON1_CLICKED)
self.assertTrue(handled)
mock_copy_msg.assert_called_once_with(raw_text, with_context=False)
def test_mouse_click_message_copy_context(self):
"""Verify clicking an individual [📑+ Context] button copies that message with full context."""
self.tui._render_transcript(y=1, x=24, h=25, w=76)
self.assertGreater(len(self.tui._msg_copy_context_buttons), 0)
first_btn = self.tui._msg_copy_context_buttons[0]
msg_y, x_start, x_end, raw_text = first_btn
click_x = (x_start + x_end) // 2
with patch.object(self.tui, "copy_message_to_clipboard") as mock_copy_msg:
handled = self.tui._handle_mouse(click_x, msg_y, curses.BUTTON1_CLICKED)
self.assertTrue(handled)
mock_copy_msg.assert_called_once_with(raw_text, with_context=True)
def test_normal_hotkey_y(self):
"""Verify 'y' in Normal mode copies latest message."""
self.tui.editor_mode = "NORMAL"
with patch.object(self.tui, "copy_message_to_clipboard") as mock_copy_msg:
handled = self.tui._handle_normal_key(ord("y"))
self.assertTrue(handled)
mock_copy_msg.assert_called_once()
def test_normal_hotkey_Y(self):
"""Verify 'Y' in Normal mode copies full transcript."""
self.tui.editor_mode = "NORMAL"
with patch.object(self.tui, "copy_transcript_to_clipboard") as mock_copy_all:
handled = self.tui._handle_normal_key(ord("Y"))
self.assertTrue(handled)
mock_copy_all.assert_called_once()
def test_slash_command_copy_all(self):
"""Verify '/copy' or '/yank' copies full transcript."""
with patch.object(self.tui, "copy_transcript_to_clipboard") as mock_copy_all:
self.tui._execute_input_line("/copy")
mock_copy_all.assert_called_once()
with patch.object(self.tui, "copy_transcript_to_clipboard") as mock_copy_all:
self.tui._execute_input_line("/yank")
mock_copy_all.assert_called_once()
def test_slash_command_copy_last(self):
"""Verify '/copy last' copies latest message."""
with patch.object(self.tui, "copy_message_to_clipboard") as mock_copy_msg:
self.tui._execute_input_line("/copy last")
mock_copy_msg.assert_called_once()
def test_enter_visual_copy_mode(self):
"""Pressing 'v' enters Visual Copy Mode with selection on visible line."""
self.tui.editor_mode = "NORMAL"
self.tui.copy_mode = False
self.tui._render_transcript(y=1, x=24, h=25, w=76)
ret = self.tui._handle_normal_key(ord('v'))
self.assertTrue(ret)
self.assertTrue(self.tui.copy_mode)
self.assertEqual(self.tui.focus_pane, "transcript")
self.assertIsNotNone(self.tui.visual_sel_start)
self.assertEqual(self.tui.visual_sel_start, self.tui.copy_cursor_line)
self.assertIn("VISUAL line copy mode", self.tui.toast_msg)
def test_enter_visual_copy_mode_V(self):
"""Pressing 'V' on transcript enters visual copy mode."""
self.tui.editor_mode = "NORMAL"
self.tui.focus_pane = "transcript"
self.tui.copy_mode = False
self.tui._render_transcript(y=1, x=24, h=25, w=76)
ret = self.tui._handle_normal_key(ord('V'))
self.assertTrue(ret)
self.assertTrue(self.tui.copy_mode)
self.assertIsNotNone(self.tui.visual_sel_start)
self.assertIn("VISUAL line copy mode", self.tui.toast_msg)
def test_message_context_menu_copy_clean_and_context(self):
"""Verify message_context modal options 2/y (clean) and 3/Y (context) work."""
target_msg = {
"role": "assistant",
"text": "> Replying to USER (seq:4):\n> Previous line\n\nClean response text",
"message_id": "msg-xyz-123",
"seq": 5,
}
self.tui.context_message = target_msg
self.tui.modal = "message_context"
with patch.object(self.tui, "copy_message_to_clipboard") as mock_copy:
# Key '2' or 'y' copies clean text (with_context=False)
self.tui._handle_modal_key(ord('2'))
mock_copy.assert_called_with(target_msg["text"], with_context=False)
self.tui.context_message = target_msg
self.tui.modal = "message_context"
with patch.object(self.tui, "copy_message_to_clipboard") as mock_copy:
# Key '3' or 'Y' copies with context (with_context=True)
self.tui._handle_modal_key(ord('3'))
mock_copy.assert_called_with(target_msg["text"], with_context=True)
def test_copy_mode_navigation_and_yank_with_border_stripping(self):
"""Navigating with 'j'/'k' and yanking with 'y' copies clean text without box borders."""
self.tui._render_transcript(y=1, x=24, h=25, w=76)
self.tui.copy_mode = True
self.tui.visual_sel_start = 2
self.tui.copy_cursor_line = 2
# Move down to line 4
self.tui._handle_copy_mode_key(ord('j'))
self.assertEqual(self.tui.copy_cursor_line, 3)
self.tui._handle_copy_mode_key(ord('j'))
self.assertEqual(self.tui.copy_cursor_line, 4)
# Yank selection with 'y'
with patch.object(muse_tui, "copy_to_clipboard", return_value=True) as mock_clip:
self.tui._handle_copy_mode_key(ord('y'))
self.assertFalse(self.tui.copy_mode)
self.assertIsNone(self.tui.visual_sel_start)
mock_clip.assert_called_once()
copied = mock_clip.call_args[0][0]
# Ensure vertical box lines '│ ' are stripped
self.assertNotIn("│ ", copied)
self.assertIn("Copied", self.tui.toast_msg)
def test_mouse_drag_select_and_release_copy(self):
"""Dragging mouse over transcript lines highlights and copies text on release."""
self.tui._render_transcript(y=1, x=24, h=25, w=76)
# 1. Mouse down at screen y=5, x=30 (inside transcript)
self.tui._handle_mouse(mx=30, my=5, bstate=curses.BUTTON1_PRESSED)
self.assertTrue(self.tui.mouse_dragging)
self.assertTrue(self.tui.copy_mode)
start_line = self.tui.visual_sel_start
self.assertIsNotNone(start_line)
# 2. Mouse drag to y=8
self.tui._handle_mouse(mx=30, my=8, bstate=curses.REPORT_MOUSE_POSITION)
self.assertEqual(self.tui.visual_sel_start, start_line)
self.assertNotEqual(self.tui.copy_cursor_line, start_line)
# 3. Mouse release
with patch.object(muse_tui, "copy_to_clipboard", return_value=True) as mock_clip:
self.tui._handle_mouse(mx=30, my=8, bstate=curses.BUTTON1_RELEASED)
self.assertFalse(self.tui.mouse_dragging)
self.assertFalse(self.tui.copy_mode)
mock_clip.assert_called_once()
copied = mock_clip.call_args[0][0]
self.assertGreater(len(copied), 0)
self.assertIn("Copied selection", self.tui.toast_msg)
def test_copy_mode_escape_exits(self):
"""Esc or 'q' clears selection and exits copy mode."""
self.tui.copy_mode = True
self.tui.visual_sel_start = 5
self.tui.copy_cursor_line = 8
# First Esc clears selection
self.tui._handle_copy_mode_key(27)
self.assertIsNone(self.tui.visual_sel_start)
self.assertTrue(self.tui.copy_mode)
# Second Esc exits copy mode
self.tui._handle_copy_mode_key(27)
self.assertFalse(self.tui.copy_mode)
if __name__ == "__main__":
unittest.main()
+207
View File
@@ -0,0 +1,207 @@
import unittest
import time
from unittest.mock import MagicMock
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT / "bin"))
import muse_tui
class TestNotificationCounters(unittest.TestCase):
def setUp(self):
self.dm = muse_tui.FleetDataManager()
# Mock background loops from interfering with testing
self.dm.running = False
self.dm.read_msg_counts = {}
# Isolate threads and history caches for tests
self.dm.nodes = ["pip", "646"]
self.dm.threads_cache = {"pip": [], "646": []}
self.dm.history_cache = {}
def test_unread_count_calculation(self):
node = "pip"
tid = "test-thread-123"
key = (node, tid)
# Initially no messages -> unread is 0
self.assertEqual(self.dm.get_unread_count(node, tid), 0)
# 5 messages arrive in cache
self.dm.history_cache[key] = [{"text": f"msg {i}"} for i in range(5)]
self.assertEqual(self.dm.get_unread_count(node, tid), 5)
# Read 3 messages
self.dm.mark_thread_read(node, tid, count=3)
self.assertEqual(self.dm.get_unread_count(node, tid), 2)
# Read all messages
self.dm.mark_thread_read(node, tid)
self.assertEqual(self.dm.get_unread_count(node, tid), 0)
def test_node_and_fleet_unread_total(self):
# Configure test threads for pip and 646
self.dm.threads_cache["pip"] = [
{"session_id": "pip-chat-1", "is_main": True},
{"session_id": "pip-chat-2", "is_main": False},
]
self.dm.threads_cache["646"] = [
{"session_id": "646-chat-1", "is_main": True},
]
# Populate message histories
self.dm.history_cache[("pip", "pip-chat-1")] = [{"text": "1"}, {"text": "2"}]
self.dm.history_cache[("pip", "pip-chat-2")] = [{"text": "a"}, {"text": "b"}, {"text": "c"}]
self.dm.history_cache[("646", "646-chat-1")] = [{"text": "x"}]
# Before reading: pip has 5, 646 has 1, total fleet is 6
self.assertEqual(self.dm.get_node_unread_total("pip"), 5)
self.assertEqual(self.dm.get_node_unread_total("646"), 1)
self.assertEqual(self.dm.get_fleet_unread_total(), 6)
# Read pip-chat-1
self.dm.mark_thread_read("pip", "pip-chat-1")
self.assertEqual(self.dm.get_node_unread_total("pip"), 3)
self.assertEqual(self.dm.get_fleet_unread_total(), 4)
def test_dwell_clearing_logic(self):
tui = muse_tui.MuseTUI(MagicMock())
tui.data.running = False
active_node = "muse"
active_tid = "muse-thread-abc"
tui.data.active_node = active_node
tui.data.active_thread_id = active_tid
tui.data.history_cache[(active_node, active_tid)] = [{"text": "msg"}] * 4
self.assertEqual(tui.data.get_unread_count(active_node, active_tid), 4)
# Simulated dwell < 5.0 seconds
tui.cur_chat_enter_time = time.time() - 2.0
tui.last_dwell_check_target = (active_node, active_tid)
# Should not clear yet
if time.time() - tui.cur_chat_enter_time >= 5.0:
tui.data.mark_thread_read(active_node, active_tid)
self.assertEqual(tui.data.get_unread_count(active_node, active_tid), 4)
# Simulated dwell >= 5.0 seconds
tui.cur_chat_enter_time = time.time() - 5.5
if time.time() - tui.cur_chat_enter_time >= 5.0:
tui.data.mark_thread_read(active_node, active_tid)
self.assertEqual(tui.data.get_unread_count(active_node, active_tid), 0)
def test_agent_sorting_and_initial_selection(self):
# Configure nodes
self.dm.nodes = ["pip", "muse", "opm", "646"]
self.dm.threads_cache = {
"pip": [{"session_id": "p1"}],
"muse": [{"session_id": "m1"}],
"opm": [{"session_id": "o1"}],
"646": [{"session_id": "c1"}],
}
# Give opm 5 unread messages, muse 2 unreads, pip 0, 646 0
self.dm.history_cache = {
("opm", "o1"): [{"text": f"msg {i}"} for i in range(5)],
("muse", "m1"): [{"text": f"msg {i}"} for i in range(2)],
}
self.dm.agent_interactions = {"pip": 100.0, "646": 200.0, "muse": 50.0, "opm": 0.0}
self.dm.sort_nodes()
# opm should be first (highest unread = 5)
# muse should be second (unread = 2)
# 646 should be third (interaction = 200.0)
# pip should be fourth (interaction = 100.0)
self.assertEqual(self.dm.nodes, ["opm", "muse", "646", "pip"])
# Default initial_node=None in MuseTUI picks nodes[0] (top of list) and its Main Chat
tui = muse_tui.MuseTUI(MagicMock(), initial_node=None)
tui.data.running = False
self.assertEqual(tui.data.active_node, tui.data.nodes[0])
self.assertIsNotNone(tui.data.active_thread_id)
top_node = tui.data.nodes[0]
top_threads = {t.get("session_id") for t in tui.data.threads_cache.get(top_node, [])}
self.assertIn(tui.data.active_thread_id, top_threads)
def test_reply_workflow(self):
tui = muse_tui.MuseTUI(MagicMock())
tui.data.running = False
msg = {
"role": "assistant",
"message_id": "msg-987654321",
"seq": 42,
"text": "Please confirm deployment parameters before we proceed.",
}
# 1. Trigger reply
ok = tui.trigger_reply_to_message(msg)
self.assertTrue(ok)
self.assertIsNotNone(tui.reply_target)
self.assertEqual(tui.reply_target["seq"], 42)
self.assertEqual(tui.reply_target["role"], "assistant")
self.assertEqual(tui.editor_mode, "INSERT")
# 2. Cancel reply with Esc sequence
tui._read_escape_sequence = MagicMock(return_value=[]) # standalone Esc
tui._handle_escape_sequence()
self.assertIsNone(tui.reply_target)
self.assertEqual(tui.editor_mode, "NORMAL")
# 3. Trigger reply again and execute input line
tui.trigger_reply_to_message(msg)
dispatched_messages = []
tui._async_send_message = lambda node, tid, text: dispatched_messages.append((node, tid, text))
tui._execute_input_line("Confirmed, deploy now.")
self.assertIsNone(tui.reply_target)
self.assertEqual(len(dispatched_messages), 1)
node, tid, sent_text = dispatched_messages[0]
self.assertIn("> Replying to ASSISTANT (seq:42):", sent_text)
self.assertIn("Please confirm deployment parameters before we proceed.", sent_text)
self.assertIn("Confirmed, deploy now.", sent_text)
def test_sidechat_notifications_and_transcript_divider(self):
tui = muse_tui.MuseTUI(MagicMock())
tui.data.running = False
active_node = "muse"
active_tid = "sidechat-worker-42"
tui.data.active_node = active_node
tui.data.active_thread_id = active_tid
tui.data.threads_cache[active_node] = [
{"session_id": "main-chat", "is_main": True, "title": "Main Chat"},
{"session_id": active_tid, "is_main": False, "title": "Worker Task 42"},
{"session_id": "sidechat-worker-43", "is_main": False, "title": "Worker Task 43"},
]
# 3 total messages, read count is 1 -> 2 unread messages in active sidechat
tui.data.history_cache[(active_node, active_tid)] = [
{"role": "user", "text": "initial prompt", "message_id": "m1"},
{"role": "assistant", "text": "update 1", "message_id": "m2"},
{"role": "assistant", "text": "update 2", "message_id": "m3"},
]
tui.data.read_msg_counts[(active_node, active_tid)] = 1
self.assertEqual(tui.data.get_unread_count(active_node, active_tid), 2)
self.assertEqual(tui.data.get_node_unread_total(active_node), 2)
# Mock safe_addstr to capture rendered strings
rendered_texts = []
tui.safe_addstr = lambda win, y, x, text, attr=0: rendered_texts.append(text)
# 1. Render sidebar
tui._render_sidebar(0, 0, 30, 32)
# Should contain ●2 inline notification badge
has_badge = any("●2" in s for s in rendered_texts)
self.assertTrue(has_badge, "Sidechats list row or header must render inline ●2 unread badge")
# 2. Render transcript
rendered_texts.clear()
tui._render_transcript(0, 32, 30, 80)
# Should render the unread divider banner or unread pill
has_div = any("2 NEW UNREAD MESSAGE" in s for s in rendered_texts)
has_hdr_pill = any("[● 2 new]" in s for s in rendered_texts)
self.assertTrue(has_div or has_hdr_pill, "Transcript must display unread banner or pill")
if __name__ == "__main__":
unittest.main()
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""
test_paste_handling.py — Unit tests for long string / chunk paste handling in MuseTUI:
1. Multi-line chunk pasting preserves newlines and does not send immediately.
2. Enter key submits the entire chunk as a single message and single history item.
3. Bracketed paste sequences (\033[200~ ... \033[201~) are correctly parsed and captured.
4. Alt+Enter (ESC + Enter) inserts newlines for multiline composition.
5. Unbracketed rapid paste bursts preserve newlines without line-by-line send.
6. Arrow keys navigate lines within multiline buffer.
7. UTF-8 multibyte characters are correctly decoded and inserted into buffer.
8. Multiline messages or file paths starting with '/' are delivered as messages.
"""
import unittest
from unittest.mock import MagicMock, patch
import importlib.util
from pathlib import Path
import curses
REPO_ROOT = Path("/home/super/Projects/NetVM")
TUI_PATH = REPO_ROOT / "bin" / "muse-tui.py"
spec = importlib.util.spec_from_file_location("muse_tui", str(TUI_PATH))
muse_mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(muse_mod)
class TestPasteHandling(unittest.TestCase):
def setUp(self):
self.mock_stdscr = MagicMock()
self.mock_stdscr.getmaxyx.return_value = (30, 100)
self.tui = muse_mod.MuseTUI(self.mock_stdscr, initial_mode="muse", initial_node="muse")
self.tui.safe_addstr = MagicMock()
self.tui.data.running = False # Keep background thread stopped
self.tui.data.active_node = "muse"
self.tui.data.active_thread_id = "test-session"
self.tui.data.active_thread_title = "Main Chat"
self.tui.data.threads_cache["muse"] = [{"session_id": "test-session", "is_main": True}]
def test_insert_pasted_text_multiline(self):
"""Pasting a multiline chunk enters input_buf in full without sending."""
chunk = "Line 1: Hello\nLine 2: World\nLine 3: Agent instruction"
self.tui.editor_mode = "NORMAL"
self.tui.input_buf = ""
self.tui.input_cursor = 0
self.tui._insert_pasted_text(chunk)
self.assertEqual(self.tui.editor_mode, "INSERT")
self.assertEqual(self.tui.input_buf, chunk)
self.assertEqual(self.tui.input_cursor, len(chunk))
self.assertIn("3 lines", self.tui.toast_msg)
# Verify nothing was sent yet
self.assertEqual(len(self.tui.input_history), 0)
def test_multiline_send_as_single_chunk(self):
"""Submitting a multiline buffer records ONE history entry and sends ONCE."""
chunk = "Line 1: def test():\n print('NetVM')\nLine 3: return True"
self.tui.editor_mode = "INSERT"
self.tui.input_buf = chunk
self.tui.input_cursor = len(chunk)
sent_messages = []
self.tui._async_send_message = lambda node, tid, text: sent_messages.append((node, tid, text))
# Simulate user pressing Enter on the completed multiline chunk
# With no characters waiting in stdin (peek_c = -1)
self.mock_stdscr.getch.return_value = -1
ret = self.tui._handle_insert_key(10)
self.assertTrue(ret)
self.assertEqual(self.tui.input_buf, "")
self.assertEqual(len(self.tui.input_history), 1)
self.assertEqual(self.tui.input_history[0], chunk)
self.assertEqual(len(sent_messages), 1)
self.assertEqual(sent_messages[0][0], "muse")
self.assertEqual(sent_messages[0][1], "test-session")
self.assertEqual(sent_messages[0][2], chunk)
def test_bracketed_paste_capture(self):
"""\033[200~ bracketed paste reads until \033[201~ and captures content."""
payload = "Multi-line code snippet:\n```python\nx = 42\nprint(x)\n```"
# Simulate stream of getch returns: payload bytes followed by \033[201~ then -1
stream = list(payload.encode("utf-8")) + [27, ord('['), ord('2'), ord('0'), ord('1'), ord('~')]
call_idx = 0
def mock_getch():
nonlocal call_idx
if call_idx < len(stream):
val = stream[call_idx]
call_idx += 1
return val
return -1
self.mock_stdscr.getch.side_effect = mock_getch
self.tui.input_buf = ""
self.tui.input_cursor = 0
handled = self.tui._handle_bracketed_paste()
self.assertTrue(handled)
self.assertEqual(self.tui.input_buf, payload)
self.assertEqual(self.tui.input_cursor, len(payload))
self.assertEqual(self.tui.editor_mode, "INSERT")
def test_alt_enter_inserts_newline(self):
"""Alt+Enter (ESC + 10 or 13) inserts newline into input_buf."""
self.tui.editor_mode = "INSERT"
self.tui.input_buf = "First line"
self.tui.input_cursor = len(self.tui.input_buf)
# Mock _read_escape_sequence returning [10] (Enter)
with patch.object(self.tui, "_read_escape_sequence", return_value=[10]):
res = self.tui._handle_escape_sequence()
self.assertTrue(res)
self.assertEqual(self.tui.input_buf, "First line\n")
self.assertEqual(self.tui.input_cursor, len("First line\n"))
def test_multiline_cursor_navigation(self):
"""Up and Down arrow navigate across lines inside multiline input_buf."""
self.tui.editor_mode = "INSERT"
self.tui.input_buf = "Line A\nLine B\nLine C"
# Place cursor at end of "Line C"
self.tui.input_cursor = len(self.tui.input_buf)
# Press Up: cursor should move to "Line B"
self.tui._handle_insert_key(curses.KEY_UP)
curr_text = self.tui.input_buf[:self.tui.input_cursor]
self.assertTrue(curr_text.endswith("Line B") or "Line B" in curr_text)
# Press Up again: cursor should move to "Line A"
self.tui._handle_insert_key(curses.KEY_UP)
curr_text = self.tui.input_buf[:self.tui.input_cursor]
self.assertTrue(curr_text.startswith("Line A"))
# Press Down: cursor should move back down to "Line B"
self.tui._handle_insert_key(curses.KEY_DOWN)
curr_text = self.tui.input_buf[:self.tui.input_cursor]
self.assertIn("Line B", curr_text)
def test_unicode_multibyte_input(self):
"""Multi-byte UTF-8 bytes assemble into correct unicode characters."""
self.tui.editor_mode = "INSERT"
self.tui.input_buf = "Test: "
self.tui.input_cursor = len(self.tui.input_buf)
# 'é' is 0xC3, 0xA9 (195, 169)
self.tui._handle_insert_key(195)
self.assertEqual(self.tui.input_buf, "Test: ") # incomplete byte, not added yet
self.tui._handle_insert_key(169)
self.assertEqual(self.tui.input_buf, "Test: é")
self.assertEqual(self.tui.input_cursor, len("Test: é"))
def test_path_starting_with_slash_delivers_as_message(self):
"""A string starting with a file path like '/var/log/syslog' is sent as message."""
line = "/var/log/syslog contains error details"
sent = []
self.tui._async_send_message = lambda node, tid, text: sent.append(text)
self.tui._execute_input_line(line)
self.assertEqual(len(sent), 1)
self.assertEqual(sent[0], line)
if __name__ == "__main__":
unittest.main()
+449
View File
@@ -0,0 +1,449 @@
#!/usr/bin/env python3
"""
test_prompts.py — Comprehensive unit and integration test suite for:
1. PromptManager persistence, defaults, formulation, deletion, and pin sorting.
2. Compact persistent prompt shelf in View 1 (Agent Chat) and 1-click auto-fill.
3. Expanded Prompt Library modal ('P') with Vim hjkl navigation, preview scroll, and Enter/click fill.
4. Searchable chat history sends ('s' / Ctrl-R) aggregating session and history cache.
5. 'p' pinning chat sent messages to the prompt fill / list in history search and transcript view.
6. Horizontal scrolling of prompt shelf via chips bounds, navigation buttons, wheel, and '[' / ']'.
"""
import unittest
from unittest.mock import MagicMock, patch
import importlib.util
from pathlib import Path
import tempfile
import time
import json
import curses
REPO_ROOT = Path("/home/super/Projects/NetVM")
TUI_PATH = REPO_ROOT / "bin" / "muse-tui.py"
spec = importlib.util.spec_from_file_location("muse_tui", str(TUI_PATH))
muse_mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(muse_mod)
class TestPromptManager(unittest.TestCase):
"""Test suite covering PromptManager storage, formulation, and pin ranking."""
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.storage_file = Path(self.temp_dir.name) / "test_prompts.json"
self.pm = muse_mod.PromptManager(storage_path=self.storage_file)
def tearDown(self):
self.temp_dir.cleanup()
def test_default_prompts_seeded_and_persisted(self):
"""Verify PromptManager initializes with default prompts and writes to disk."""
self.assertTrue(self.storage_file.exists())
prompts = self.pm.get_sorted_prompts()
self.assertGreaterEqual(len(prompts), 5)
titles = [p["title"] for p in prompts]
self.assertIn("Fleet Audit", titles)
self.assertIn("Work Order", titles)
self.assertIn("CDP Inspect", titles)
def test_add_and_delete_prompt(self):
"""Verify formulation of custom prompt skills and subsequent deletion."""
new_p = self.pm.add_prompt(
title="Custom Health Probe",
prompt_text="Run detailed tcpdump and box health probes on all nodes.",
tags=["probe", "custom"],
pinned=True
)
self.assertEqual(new_p["title"], "Custom Health Probe")
self.assertTrue(new_p["pinned"])
# Check persistence
reloaded = muse_mod.PromptManager(storage_path=self.storage_file)
prompts = reloaded.get_sorted_prompts()
self.assertTrue(any(p["id"] == new_p["id"] for p in prompts))
# Delete
self.assertTrue(self.pm.delete_prompt(new_p["id"]))
prompts_after = self.pm.get_sorted_prompts()
self.assertFalse(any(p["id"] == new_p["id"] for p in prompts_after))
def test_toggle_pin_prompt(self):
"""Verify toggle_pin_prompt alters pinned flag and affects sorted order."""
prompts = self.pm.get_sorted_prompts()
target = prompts[-1] # Get unpinned prompt
target_id = target["id"]
new_state = self.pm.toggle_pin_prompt(target_id)
self.assertTrue(new_state)
# In sorted prompts, newly pinned should be among first
sorted_p = self.pm.get_sorted_prompts()
first_few_ids = [p["id"] for p in sorted_p[:4]]
self.assertIn(target_id, first_few_ids)
def test_pin_chat_send(self):
"""Verify pinning a chat sent message formats title, pins to top, and persists."""
sent_text = "Investigate high CDP drop rates and verify routing table on node 646."
prompt, is_new = self.pm.pin_chat_send(sent_text, node="646")
self.assertTrue(is_new)
self.assertTrue(prompt["pinned"])
self.assertEqual(prompt["prompt"], sent_text)
self.assertIn("Investigate high CDP", prompt["title"])
# Pinned chat send should be first in sorted prompts
sorted_p = self.pm.get_sorted_prompts()
self.assertEqual(sorted_p[0]["id"], prompt["id"])
# Pinning same text again should mark pinned without creating duplicate
p2, is_new2 = self.pm.pin_chat_send(sent_text, node="646")
self.assertFalse(is_new2)
self.assertEqual(p2["id"], prompt["id"])
def test_toggle_pin_chat_send(self):
"""Verify toggle_pin_chat_send flips pinned state between True and False."""
sent_text = "Investigate high CDP drop rates and verify routing table."
p1, is_new = self.pm.toggle_pin_chat_send(sent_text, node="646")
self.assertTrue(is_new)
self.assertTrue(p1["pinned"])
# Toggle again: should unpin
p2, is_new2 = self.pm.toggle_pin_chat_send(sent_text, node="646")
self.assertFalse(is_new2)
self.assertFalse(p2["pinned"])
# Toggle again: should re-pin
p3, is_new3 = self.pm.toggle_pin_chat_send(sent_text, node="646")
self.assertFalse(is_new3)
self.assertTrue(p3["pinned"])
class TestPromptTUIIntegration(unittest.TestCase):
"""Test suite covering TUI compact shelf, click auto-fill, modals, and sends search."""
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.storage_file = Path(self.temp_dir.name) / "test_prompts.json"
self.mock_stdscr = MagicMock()
self.mock_stdscr.getmaxyx.return_value = (30, 100)
self.tui = muse_mod.MuseTUI(self.mock_stdscr, initial_mode="muse", initial_node="muse")
self.tui.data.stop()
self.tui.safe_addstr = MagicMock()
# Replace prompt manager with isolated instance
self.tui.prompt_manager = muse_mod.PromptManager(storage_path=self.storage_file)
self.tui.prompts = self.tui.prompt_manager.get_sorted_prompts()
# Clear history cache to isolate unit test data
self.tui.data.history_cache.clear()
# Seed sample chat history
self.tui.data.history_cache[("muse", "thread_1")] = [
{"role": "user", "text": "Check all docker network bridges.", "message_id": "m1"},
{"role": "assistant", "text": "All docker network bridges are operational.", "message_id": "m2"},
{"role": "user", "text": "Inspect CDP latency on pip and opm.", "message_id": "m3"},
]
self.tui.data.active_node = "muse"
self.tui.data.active_thread_id = "thread_1"
self.tui.data.active_thread_title = "thread_1"
self.tui.input_history = ["Recent typed command from session"]
def tearDown(self):
self.temp_dir.cleanup()
def test_compact_prompt_shelf_render_and_chip_bounds(self):
"""Verify _render_prompt_shelf populates chip bounds and button boundaries."""
self.tui._render_prompt_shelf(y=24, x=0, h=2, w=100)
bounds = self.tui._prompt_chip_bounds
self.assertGreater(len(bounds), 0, "Prompt chips should be registered with bounding boxes")
first_start, first_end, first_prompt = bounds[0]
self.assertGreater(first_end, first_start)
self.assertIn("title", first_prompt)
self.assertTrue(hasattr(self.tui, "_btn_expand_bounds"))
self.assertTrue(hasattr(self.tui, "_btn_sends_bounds"))
def test_one_click_auto_fill_from_shelf(self):
"""Verify clicking a prompt chip immediately auto-fills input_buf and sets INSERT mode."""
self.tui._render_prompt_shelf(y=24, x=0, h=2, w=100)
first_start, first_end, first_prompt = self.tui._prompt_chip_bounds[0]
# Simulate click on chip
click_x = (first_start + first_end) // 2
click_y = 25 # Row 1 of prompt shelf (chip row)
res = self.tui._handle_mouse(mx=click_x, my=click_y, bstate=curses.BUTTON1_CLICKED)
self.assertTrue(res)
# Input buffer should contain prompt text
self.assertEqual(self.tui.input_buf, first_prompt["prompt"])
self.assertEqual(self.tui.input_cursor, len(first_prompt["prompt"]))
self.assertEqual(self.tui.editor_mode, "INSERT")
self.assertIn("Filled prompt", self.tui.toast_msg)
def test_expand_prompts_modal_and_vim_navigation(self):
"""Verify pressing 'P' expands modal and hjkl keys navigate and Enter fills."""
# Press capital 'P' in NORMAL mode
self.tui.editor_mode = "NORMAL"
self.tui.focus_pane = "transcript"
res = self.tui._handle_key(ord('P'))
self.assertTrue(res)
self.assertEqual(self.tui.modal, "prompts")
self.assertEqual(self.tui.prompt_sel_idx, 0)
# 'j' moves down
self.tui._handle_key(ord('j'))
self.assertEqual(self.tui.prompt_sel_idx, 1)
# 'k' moves up
self.tui._handle_key(ord('k'))
self.assertEqual(self.tui.prompt_sel_idx, 0)
# 'G' jumps to last
self.tui._handle_key(ord('G'))
self.assertEqual(self.tui.prompt_sel_idx, len(self.tui.prompts) - 1)
# 'g' jumps to first
self.tui._handle_key(ord('g'))
self.assertEqual(self.tui.prompt_sel_idx, 0)
# Enter fills selected prompt and switches to INSERT
expected_text = self.tui.prompts[0]["prompt"]
self.tui._handle_key(10) # Enter
self.assertIsNone(self.tui.modal)
self.assertEqual(self.tui.input_buf, expected_text)
self.assertEqual(self.tui.editor_mode, "INSERT")
def test_searchable_chat_history_sends(self):
"""Verify get_chat_sends aggregates past sends and filters with query."""
sends = self.tui.get_chat_sends()
self.assertGreaterEqual(len(sends), 3)
texts = [s["text"] for s in sends]
self.assertIn("Recent typed command from session", texts)
self.assertIn("Check all docker network bridges.", texts)
self.assertIn("Inspect CDP latency on pip and opm.", texts)
# Filter query
filtered = self.tui.get_chat_sends("docker")
self.assertEqual(len(filtered), 1)
self.assertEqual(filtered[0]["text"], "Check all docker network bridges.")
def test_pin_chat_send_to_prompt_fill_in_history_search(self):
"""Verify 'p' in history_search toggles pin and unpin for highlighted send."""
self.tui.modal = "history_search"
self.tui.modal_input_buf = ""
self.tui.history_search_sel = 0
initial_prompts_count = len(self.tui.prompts)
sends = self.tui.get_chat_sends()
target_send_text = sends[0]["text"]
# Press 'p': 1st time pins
res = self.tui._handle_key(ord('p'))
self.assertTrue(res)
self.assertEqual(len(self.tui.prompts), initial_prompts_count + 1)
top_prompt = self.tui.prompts[0]
self.assertTrue(top_prompt["pinned"])
self.assertEqual(top_prompt["prompt"], target_send_text)
self.assertIn("Pinned", self.tui.toast_msg)
# Press 'p': 2nd time UNPINS
res2 = self.tui._handle_key(ord('p'))
self.assertTrue(res2)
target_prompt_obj = [p for p in self.tui.prompt_manager.prompts if p["prompt"] == target_send_text][0]
self.assertFalse(target_prompt_obj["pinned"])
self.assertIn("Unpinned", self.tui.toast_msg)
# Press 'p': 3rd time RE-PINS
res3 = self.tui._handle_key(ord('p'))
self.assertTrue(res3)
self.assertTrue(target_prompt_obj["pinned"])
self.assertIn("Pinned", self.tui.toast_msg)
def test_history_search_mouse_toggle_pin(self):
"""Verify clicking the pin column in history_search toggles pin/unpin."""
self.tui.modal = "history_search"
self.tui.modal_input_buf = ""
self.tui.history_search_sel = 0
sends = self.tui.get_chat_sends()
target_text = sends[0]["text"]
h, w = self.tui.stdscr.getmaxyx()
modal_w = min(74, w - 6)
modal_h = min(20, h - 4)
top_y = (h - modal_h) // 2
left_x = (w - modal_w) // 2
# 1st click pins
click_y = top_y + 4
click_x = left_x + 4
handled = self.tui._handle_mouse(click_x, click_y, curses.BUTTON1_CLICKED)
self.assertTrue(handled)
target_prompt = [p for p in self.tui.prompt_manager.prompts if p["prompt"] == target_text][0]
self.assertTrue(target_prompt["pinned"])
self.assertIn("Pinned", self.tui.toast_msg)
# 2nd click unpins
handled2 = self.tui._handle_mouse(click_x, click_y, curses.BUTTON1_CLICKED)
self.assertTrue(handled2)
self.assertFalse(target_prompt["pinned"])
self.assertIn("Unpinned", self.tui.toast_msg)
def test_transcript_view_p_pins_latest_send(self):
"""Verify pressing 'p' on transcript pins the current thread's latest sent message."""
self.tui.modal = None
self.tui.editor_mode = "NORMAL"
self.tui.focus_pane = "transcript"
initial_count = len(self.tui.prompts)
# In current thread, latest user message is "Inspect CDP latency on pip and opm."
res = self.tui._handle_key(ord('p'))
self.assertTrue(res)
self.assertEqual(len(self.tui.prompts), initial_count + 1)
self.assertEqual(self.tui.prompts[0]["prompt"], "Inspect CDP latency on pip and opm.")
self.assertTrue(self.tui.prompts[0]["pinned"])
self.assertIn("Pinned", self.tui.toast_msg)
def test_sidebar_p_still_toggles_thread_pin(self):
"""Verify pressing 'p' when focus is on sidebar preserves thread pinning."""
self.tui.modal = None
self.tui.editor_mode = "NORMAL"
self.tui.focus_pane = "sidebar"
# Ensure thread_1 is unpinned initially
self.tui.data.pinned_threads.setdefault("muse", set()).discard("thread_1")
# Mock threads cache
self.tui.data.threads_cache["muse"] = [
{"session_id": "thread_1", "title": "Test Chat", "is_main": False}
]
self.tui.thread_sel_idx = 0
res = self.tui._handle_key(ord('p'))
self.assertTrue(res)
self.assertIn("thread_1", self.tui.data.pinned_threads.get("muse", set()))
self.assertIn("Pinned chat", self.tui.toast_msg)
# Clean up disk state
self.tui.data.toggle_pin_thread("muse", "thread_1")
def test_bracket_keys_scroll_prompt_shelf(self):
"""Verify '[' and ']' scroll the compact prompt shelf horizontally."""
self.tui.editor_mode = "NORMAL"
self.tui.focus_pane = "transcript"
self.tui.prompt_scroll_idx = 0
self.tui._handle_key(ord(']'))
self.assertEqual(self.tui.prompt_scroll_idx, 1)
self.tui._handle_key(ord('['))
self.assertEqual(self.tui.prompt_scroll_idx, 0)
def test_slash_prompt_and_sends_commands(self):
"""Verify /prompt save and /sends slash commands formulate and open modals."""
self.tui.input_buf = "Check loop telemetry on all nodes"
self.tui._execute_input_line("/prompt save Telemetry Check")
prompts = self.tui.prompt_manager.get_sorted_prompts()
saved = [p for p in prompts if p["title"] == "Telemetry Check"]
self.assertEqual(len(saved), 1)
self.assertEqual(saved[0]["prompt"], "Check loop telemetry on all nodes")
# /prompt add
self.tui._execute_input_line("/prompt add Quick Probe | ping all nodes")
saved_add = [p for p in self.tui.prompt_manager.get_sorted_prompts() if p["title"] == "Quick Probe"]
self.assertEqual(len(saved_add), 1)
self.assertEqual(saved_add[0]["prompt"], "ping all nodes")
# /prompt del
self.tui._execute_input_line(f"/prompt del {saved_add[0]['id']}")
saved_del = [p for p in self.tui.prompt_manager.get_sorted_prompts() if p["title"] == "Quick Probe"]
self.assertEqual(len(saved_del), 0)
# /sends opens history search
self.tui._execute_input_line("/sends docker")
self.assertEqual(self.tui.modal, "history_search")
self.assertEqual(self.tui.modal_input_buf, "docker")
def test_shelf_buttons_mouse_clicks(self):
"""Verify clicking buttons on row 0 of prompt shelf opens modals and scrolls."""
self.tui._render_prompt_shelf(y=24, x=0, h=2, w=100)
# Click [P:Expand]
exp_x = (self.tui._btn_expand_bounds[0] + self.tui._btn_expand_bounds[1]) // 2
self.tui._handle_mouse(mx=exp_x, my=24, bstate=curses.BUTTON1_CLICKED)
self.assertEqual(self.tui.modal, "prompts")
self.tui.modal = None
# Click [🔍 Sends]
sends_x = (self.tui._btn_sends_bounds[0] + self.tui._btn_sends_bounds[1]) // 2
self.tui._handle_mouse(mx=sends_x, my=24, bstate=curses.BUTTON1_CLICKED)
self.assertEqual(self.tui.modal, "history_search")
self.tui.modal = None
# Click [►]
next_x = (self.tui._btn_next_bounds[0] + self.tui._btn_next_bounds[1]) // 2
self.tui.prompt_scroll_idx = 0
self.tui._handle_mouse(mx=next_x, my=24, bstate=curses.BUTTON1_CLICKED)
self.assertEqual(self.tui.prompt_scroll_idx, 1)
# Click [◄]
prev_x = (self.tui._btn_prev_bounds[0] + self.tui._btn_prev_bounds[1]) // 2
self.tui._handle_mouse(mx=prev_x, my=24, bstate=curses.BUTTON1_CLICKED)
self.assertEqual(self.tui.prompt_scroll_idx, 0)
def test_mouse_wheel_scrolling_on_shelf(self):
"""Verify mouse wheel up/down over prompt shelf scrolls chips."""
self.tui._render_prompt_shelf(y=24, x=0, h=2, w=100)
self.tui.prompt_scroll_idx = 0
# Wheel down over prompt shelf (my=25)
self.tui._handle_mouse(mx=50, my=25, bstate=0x200000)
self.assertEqual(self.tui.prompt_scroll_idx, 1)
# Wheel up over prompt shelf (my=25)
self.tui._handle_mouse(mx=50, my=25, bstate=0x10000)
self.assertEqual(self.tui.prompt_scroll_idx, 0)
def test_prompt_modal_pin_toggle_and_delete(self):
"""Verify 'p' toggles pin and 'd' deletes in prompt modal."""
self.tui.modal = "prompts"
self.tui.prompt_sel_idx = 0
target = self.tui.prompts[0]
initial_pin = target.get("pinned", False)
# Press 'p' to toggle pin
self.tui._handle_key(ord('p'))
toggled = [p for p in self.tui.prompts if p["id"] == target["id"]][0]
self.assertEqual(toggled.get("pinned", False), not initial_pin)
# Now select the prompt and press 'd' to delete
target_idx = [i for i, p in enumerate(self.tui.prompts) if p["id"] == target["id"]][0]
self.tui.prompt_sel_idx = target_idx
self.tui._handle_key(ord('d'))
self.assertFalse(any(p["id"] == target["id"] for p in self.tui.prompts))
def test_formulate_prompt_modal(self):
"""Verify prompt_formulate modal creates and saves new prompt."""
self.tui.modal = "prompt_formulate"
self.tui.input_buf = "Draft message from input composer"
self.tui.modal_input_buf = "New Diagnostic"
self.tui.modal_input_cursor = len(self.tui.modal_input_buf)
# Press Enter
self.tui._handle_key(10)
self.assertIsNone(self.tui.modal)
# Check prompt exists in manager
matching = [p for p in self.tui.prompts if p["title"] == "New Diagnostic"]
self.assertEqual(len(matching), 1)
self.assertEqual(matching[0]["prompt"], "Draft message from input composer")
self.assertTrue(matching[0]["pinned"])
if __name__ == "__main__":
unittest.main()
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""
test_rate_limits.py — Tests for rate-limiting, adaptive polling, backoff, and circuit breaker.
"""
import curses
import importlib.util
import json
import os
import sys
import time
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
REPO_ROOT = Path(__file__).resolve().parent.parent
MUSE_TUI_PATH = REPO_ROOT / "bin" / "muse-tui.py"
spec = importlib.util.spec_from_file_location("muse_tui_rl", MUSE_TUI_PATH)
muse_tui_rl = importlib.util.module_from_spec(spec)
sys.modules["muse_tui_rl"] = muse_tui_rl
spec.loader.exec_module(muse_tui_rl)
FleetDataManager = muse_tui_rl.FleetDataManager
MuseTUI = muse_tui_rl.MuseTUI
run_command_isolated = muse_tui_rl.run_command_isolated
class TestRateLimitingAndBackoff(unittest.TestCase):
def setUp(self):
# In tests, start_poller is disabled by default
self.mgr = FleetDataManager(start_poller=False)
def test_poller_disabled_in_tests_by_default(self):
"""FleetDataManager does not leak background poller threads during unit test runs."""
self.assertIsNone(self.mgr.poller_thread)
def test_rate_limit_cooldown_marking(self):
"""Marking a node as rate-limited sets cooldown window and rate_limited flag."""
self.assertFalse(self.mgr.is_node_rate_limited("muse"))
self.assertEqual(self.mgr.get_node_cooldown_remaining("muse"), 0)
self.mgr.mark_node_rate_limited("muse", cooldown_seconds=30.0, reason="HTTP 429")
self.assertTrue(self.mgr.is_node_rate_limited("muse"))
self.assertGreater(self.mgr.get_node_cooldown_remaining("muse"), 20)
self.assertTrue(self.mgr.node_status["muse"]["rate_limited"])
def test_tiered_backoff_progression(self):
"""Consecutive rate-limit occurrences escalate (30s -> 60s -> 120s max), and clear resets."""
# Incident 1: 30s
self.mgr.mark_node_rate_limited("pip", reason="429 first")
cd1 = self.mgr.get_node_cooldown_remaining("pip")
self.assertTrue(25 <= cd1 <= 31)
# Incident 2: 60s
self.mgr.mark_node_rate_limited("pip", reason="429 second")
cd2 = self.mgr.get_node_cooldown_remaining("pip")
self.assertTrue(55 <= cd2 <= 61)
# Incident 3: 120s max
self.mgr.mark_node_rate_limited("pip", reason="429 third")
cd3 = self.mgr.get_node_cooldown_remaining("pip")
self.assertTrue(110 <= cd3 <= 121)
# Success resets backoff
self.mgr.clear_node_rate_limit("pip")
self.assertFalse(self.mgr.is_node_rate_limited("pip"))
self.assertEqual(self.mgr.get_node_cooldown_remaining("pip"), 0)
self.assertEqual(self.mgr.rate_limit_consecutive["pip"], 0)
def test_retry_after_header_parsing(self):
"""When output includes Retry-After, that explicit duration is used."""
with patch.object(muse_tui_rl, "run_command_isolated", return_value=(1, "", "Error 429: Too Many Requests. Retry-After: 85")):
self.mgr._fetch_history("646", "thread-xyz")
self.assertTrue(self.mgr.is_node_rate_limited("646"))
cd = self.mgr.get_node_cooldown_remaining("646")
self.assertTrue(80 <= cd <= 86)
def test_rate_limited_node_skips_fetches(self):
"""Rate-limited nodes are skipped by _fetch_history and _run_fetch_threads."""
self.mgr.mark_node_rate_limited("muse", cooldown_seconds=60.0)
with patch.object(muse_tui_rl, "run_command_isolated") as mock_cmd:
self.mgr._fetch_history("muse", "thread-123")
mock_cmd.assert_not_called()
self.mgr._run_fetch_threads("muse")
mock_cmd.assert_not_called()
def test_rate_limit_detection_from_command_output(self):
"""When CLI command outputs 429 or rate limit text, node is placed on cooldown."""
with patch.object(muse_tui_rl, "run_command_isolated", return_value=(1, "", "Error 429: Too Many Requests")):
self.mgr._fetch_history("pip", "thread-abc")
self.assertTrue(self.mgr.is_node_rate_limited("pip"))
self.assertGreater(self.mgr.get_node_cooldown_remaining("pip"), 0)
def test_record_activity_updates_timestamp(self):
"""record_activity updates last_user_activity timestamp."""
old_time = self.mgr.last_user_activity
time.sleep(0.01)
self.mgr.record_activity()
self.assertGreaterEqual(self.mgr.last_user_activity, old_time)
def test_run_command_isolated_handles_quick_command(self):
"""run_command_isolated runs a command and returns returncode, stdout, stderr."""
rc, stdout, stderr = run_command_isolated(["echo", "hello rate limit"], timeout=2.0)
self.assertEqual(rc, 0)
self.assertIn("hello rate limit", stdout)
def test_run_command_isolated_terminates_on_timeout(self):
"""run_command_isolated cleanly kills process group on timeout without zombies."""
rc, stdout, stderr = run_command_isolated(["sleep", "10"], timeout=0.1)
self.assertEqual(rc, -1)
self.assertIn("timed out", stderr)
class TestRateLimitUserInteraction(unittest.TestCase):
def setUp(self):
self.mock_stdscr = MagicMock()
self.mock_stdscr.getmaxyx.return_value = (30, 100)
self.mock_stdscr.getch.return_value = -1
self.tui = MuseTUI(self.mock_stdscr, initial_mode="muse", initial_node="muse")
self.tui.safe_addstr = MagicMock()
def test_soft_guardrail_send_during_cooldown(self):
"""Sending during cooldown warns on first Enter, preserves buffer, and forces on second Enter."""
self.tui.data.mark_node_rate_limited("muse", cooldown_seconds=30.0)
self.tui.editor_mode = "INSERT"
self.tui.input_buf = "Status report please"
self.tui.input_cursor = len(self.tui.input_buf)
# First Enter tap: warns and does not clear input_buf
with patch.object(self.tui, "_async_send_message") as mock_send:
self.tui._handle_insert_key(10)
mock_send.assert_not_called()
self.assertEqual(self.tui.input_buf, "Status report please")
self.assertIn("cooldown", self.tui.toast_msg)
self.assertIn("Press Enter again", self.tui.toast_msg)
# Second Enter tap within 2.5s: bypasses cooldown, loads into history, and delivers
with patch("threading.Thread") as mock_thread:
self.tui._handle_insert_key(10)
self.assertFalse(self.tui.data.is_node_rate_limited("muse"))
self.assertIn("Sending message to MUSE", self.tui.toast_msg)
self.assertEqual(self.tui.input_buf, "")
# Ensure message was immediately loaded into history cache!
msgs = self.tui.data.history_cache.get(("muse", self.tui.data.active_thread_id), [])
self.assertTrue(any(m.get("text") == "Status report please" and m.get("role") == "user" for m in msgs))
def test_two_tap_manual_sync_override(self):
"""Pressing 'r' during cooldown warns on first press and bypasses cooldown on double-tap."""
self.tui.data.mark_node_rate_limited("muse", cooldown_seconds=45.0)
# First 'r' tap: warns
self.tui._handle_normal_key(ord('r'))
self.assertTrue(self.tui.data.is_node_rate_limited("muse"))
self.assertIn("cooling down", self.tui.toast_msg)
self.assertIn("Press 'r' again", self.tui.toast_msg)
# Second 'r' tap within 2s: clears rate limit and forces sync
with patch.object(self.tui.data, "lazy_fetch_threads") as mock_fetch:
self.tui._handle_normal_key(ord('r'))
self.assertFalse(self.tui.data.is_node_rate_limited("muse"))
self.assertIn("Force-syncing", self.tui.toast_msg)
mock_fetch.assert_called_with("muse", force=True)
def test_agent_context_menu_sync_override(self):
"""Context menu 'sync' action also respects two-tap override during cooldown."""
self.tui.data.mark_node_rate_limited("pip", cooldown_seconds=30.0)
self.tui.context_agent = {"node": "pip"}
# First selection: warns
self.tui._execute_agent_action("sync")
self.assertTrue(self.tui.data.is_node_rate_limited("pip"))
self.assertIn("cooling down", self.tui.toast_msg)
# Second selection within 2s: forces sync
with patch.object(self.tui.data, "lazy_fetch_threads") as mock_fetch:
self.tui._execute_agent_action("sync")
self.assertFalse(self.tui.data.is_node_rate_limited("pip"))
self.assertIn("Force-syncing", self.tui.toast_msg)
mock_fetch.assert_called_with("pip", force=True)
def test_optimistic_message_persistence_across_fetch(self):
"""Optimistic user messages are preserved even if server history lags behind."""
self.tui.data.history_cache[("muse", "sess-test")] = []
self.tui.data.add_optimistic_message("muse", "sess-test", "New uncommitted instruction")
cached = self.tui.data.history_cache.get(("muse", "sess-test"), [])
self.assertEqual(len(cached), 1)
self.assertEqual(cached[0]["text"], "New uncommitted instruction")
# Simulate remote fetch returning older history that doesn't yet have the new message
old_server_msgs = [{"role": "assistant", "text": "Earlier reply"}]
with patch.object(muse_tui_rl, "run_command_isolated", return_value=(0, json.dumps(old_server_msgs), "")):
self.tui.data._fetch_history("muse", "sess-test")
updated = self.tui.data.history_cache.get(("muse", "sess-test"), [])
# Both the older server message AND the pending user message should exist!
self.assertEqual(len(updated), 2)
self.assertEqual(updated[0]["text"], "Earlier reply")
self.assertEqual(updated[1]["text"], "New uncommitted instruction")
if __name__ == "__main__":
import json
unittest.main()
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""
test_scrollback.py — Comprehensive unit and integration test suite for:
1. Transcript scrollback rendering, line wrapping, and frame cache.
2. Jump-to-top ('g', Home) and bounds-capped scrolling ('k', Up arrow, PgUp).
3. Immediate downward movement ('j', Down arrow, PgDn) without 9999 offset blockage.
4. Jump-to-bottom ('G', End) re-engaging auto_scroll = True.
5. Thread and node switching resetting transcript scroll offset to 0.
6. Message send input line execution resetting scroll offset to bottom.
7. Mouse wheel scrolling within bounds.
"""
import unittest
from unittest.mock import MagicMock, patch
import importlib.util
from pathlib import Path
import curses
REPO_ROOT = Path("/home/super/Projects/NetVM")
TUI_PATH = REPO_ROOT / "bin" / "muse-tui.py"
spec = importlib.util.spec_from_file_location("muse_tui", str(TUI_PATH))
muse_mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(muse_mod)
class TestScrollbackSubsystem(unittest.TestCase):
"""Test suite covering transcript scrollback, bounds clamping, and cache performance."""
def setUp(self):
self.mock_stdscr = MagicMock()
self.mock_stdscr.getmaxyx.return_value = (30, 100)
self.tui = muse_mod.MuseTUI(self.mock_stdscr, initial_mode="muse", initial_node="muse")
self.tui.safe_addstr = MagicMock()
# Seed realistic history cache (50 messages, multi-line)
test_msgs = []
for i in range(50):
role = "user" if i % 2 == 0 else "assistant"
test_msgs.append({
"role": role,
"text": f"Message {i}: This is a test message to simulate rich chat transcript history.\nLine 2 of message {i}",
"seq": i,
"message_id": f"msg-{i:04d}",
})
self.tui.data.history_cache[("muse", "test_thread")] = test_msgs
self.tui.data.active_node = "muse"
self.tui.data.active_thread_id = "test_thread"
self.tui.data.active_thread_title = "Test Thread"
def test_initial_render_and_line_caching(self):
"""Verify _render_transcript populates cache and calculates total lines."""
self.tui._render_transcript(y=2, x=24, h=26, w=76)
total_lines = self.tui.transcript_total_lines
self.assertGreater(total_lines, 50, "50 multi-line messages should yield > 50 rendered lines")
self.assertIsNotNone(self.tui._transcript_cache_key)
self.assertGreater(len(self.tui._transcript_cache_lines), 0)
self.assertTrue(self.tui.auto_scroll)
self.assertEqual(self.tui.transcript_scroll_offset, 0)
# Re-render with same parameters: should hit cache without recomputing lines
cached_lines_ref = self.tui._transcript_cache_lines
self.tui._render_transcript(y=2, x=24, h=26, w=76)
self.assertIs(self.tui._transcript_cache_lines, cached_lines_ref)
def test_jump_to_top_and_down_scrolling(self):
"""Verify 'g' jumps to top without 9999 offset bug, and 'j' immediately scrolls down."""
# 1. Initial render to establish dimensions
self.tui._render_transcript(y=2, x=24, h=26, w=76)
max_scroll = max(0, self.tui.transcript_total_lines - self.tui.transcript_height)
self.assertGreater(max_scroll, 0)
# 2. Press 'g' (or Home) to jump to top
self.tui.focus_pane = "transcript"
handled = self.tui._handle_normal_key(ord('g'))
self.assertTrue(handled)
self.assertFalse(self.tui.auto_scroll)
self.assertEqual(self.tui.transcript_scroll_offset, max_scroll, "Jump to top must equal max_scroll, not 9999")
# 3. Press 'j' (Down arrow) to scroll down by 3 lines
handled = self.tui._handle_normal_key(ord('j'))
self.assertTrue(handled)
self.assertEqual(self.tui.transcript_scroll_offset, max_scroll - 3, "Down arrow must immediately decrement offset")
# 4. Render frame: bounds check should preserve offset
self.tui._render_transcript(y=2, x=24, h=26, w=76)
self.assertEqual(self.tui.transcript_scroll_offset, max_scroll - 3)
def test_jump_to_bottom_re_engages_auto_scroll(self):
"""Verify 'G' (or End) resets offset to 0 and re-engages auto_scroll."""
self.tui._render_transcript(y=2, x=24, h=26, w=76)
self.tui.focus_pane = "transcript"
# Scroll up
self.tui._handle_normal_key(ord('k'))
self.assertFalse(self.tui.auto_scroll)
self.assertGreater(self.tui.transcript_scroll_offset, 0)
# Press 'G' to jump to bottom
handled = self.tui._handle_normal_key(ord('G'))
self.assertTrue(handled)
self.assertTrue(self.tui.auto_scroll)
self.assertEqual(self.tui.transcript_scroll_offset, 0)
def test_uncapped_scroll_up_prevented(self):
"""Verify repeated 'k' / PageUp keys never exceed max_scroll."""
self.tui._render_transcript(y=2, x=24, h=26, w=76)
self.tui.focus_pane = "transcript"
max_scroll = max(0, self.tui.transcript_total_lines - self.tui.transcript_height)
# Hammer scroll up 100 times
for _ in range(100):
self.tui._handle_normal_key(ord('k'))
self.assertEqual(self.tui.transcript_scroll_offset, max_scroll, "Scroll offset must be capped at max_scroll")
# Render frame
self.tui._render_transcript(y=2, x=24, h=26, w=76)
self.assertEqual(self.tui.transcript_scroll_offset, max_scroll)
def test_mouse_wheel_scrolling(self):
"""Verify mouse wheel up scrolls up and mouse wheel down scrolls back to auto_scroll."""
self.tui._render_transcript(y=2, x=24, h=26, w=76)
content_y, content_h = 2, 26
sidebar_w = 24
transcript_mx = sidebar_w + 10 # Mouse inside transcript pane
transcript_my = content_y + 5
# Mouse wheel up mask
wheel_up_mask = 0x10000
self.tui._handle_mouse(transcript_mx, transcript_my, wheel_up_mask)
self.assertFalse(self.tui.auto_scroll)
self.assertEqual(self.tui.transcript_scroll_offset, 3)
# Mouse wheel down mask (curses.BUTTON5_PRESSED or 0x200000)
wheel_down_mask = getattr(curses, "BUTTON5_PRESSED", 0x200000)
self.tui._handle_mouse(transcript_mx, transcript_my, wheel_down_mask)
self.assertEqual(self.tui.transcript_scroll_offset, 0)
self.assertTrue(self.tui.auto_scroll)
def test_target_switch_resets_scroll(self):
"""Verify switching active node or thread automatically resets scroll state."""
self.tui._render_transcript(y=2, x=24, h=26, w=76)
self.tui.focus_pane = "transcript"
self.tui._handle_normal_key(ord('g')) # Scrolled all the way up
self.assertFalse(self.tui.auto_scroll)
self.assertGreater(self.tui.transcript_scroll_offset, 0)
# Switch to another thread
self.tui.data.history_cache[("muse", "other_thread")] = [
{"role": "user", "text": "Hello new thread", "seq": 1, "message_id": "m1"}
]
self.tui.data.active_thread_id = "other_thread"
# Next render detects target switch
self.tui._render_transcript(y=2, x=24, h=26, w=76)
self.assertTrue(self.tui.auto_scroll, "Target switch must restore auto_scroll")
self.assertEqual(self.tui.transcript_scroll_offset, 0, "Target switch must reset transcript_scroll_offset")
def test_message_send_resets_scroll(self):
"""Verify sending a chat message resets scroll offset to follow live response."""
self.tui._render_transcript(y=2, x=24, h=26, w=76)
self.tui.focus_pane = "transcript"
self.tui._handle_normal_key(ord('g'))
self.assertFalse(self.tui.auto_scroll)
self.assertGreater(self.tui.transcript_scroll_offset, 0)
# Mock async send to avoid actual network / subprocess calls
with patch.object(self.tui, "_async_send_message"):
self.tui._execute_input_line("Test message from operator")
self.assertTrue(self.tui.auto_scroll, "Message send must restore auto_scroll")
self.assertEqual(self.tui.transcript_scroll_offset, 0, "Message send must reset scroll offset to 0")
def test_insert_mode_typing_and_batch_processing(self):
"""Verify insert mode appends characters and updates cursor cleanly."""
self.tui.editor_mode = "INSERT"
for ch in "hello netvm":
handled = self.tui._handle_insert_key(ord(ch))
self.assertTrue(handled)
self.assertEqual(self.tui.input_buf, "hello netvm")
self.assertEqual(self.tui.input_cursor, 11)
# Backspace
self.tui._handle_insert_key(curses.KEY_BACKSPACE)
self.assertEqual(self.tui.input_buf, "hello netv")
self.assertEqual(self.tui.input_cursor, 10)
if __name__ == "__main__":
unittest.main()