diff --git a/src/command_system/builtins.py b/src/command_system/builtins.py index 12043426b..14f40c28d 100644 --- a/src/command_system/builtins.py +++ b/src/command_system/builtins.py @@ -41,6 +41,7 @@ from .memory_command import MEMORY_COMMAND from .stickers_command import STICKERS_COMMAND from .rename_command import RENAME_COMMAND +from .resume_command import RESUME_COMMAND from .types import Command, CommandType, CompactionResult, LocalCommand, PromptCommand @@ -1260,6 +1261,7 @@ def get_builtin_commands() -> list[Command]: MEMORY_COMMAND, STICKERS_COMMAND, RENAME_COMMAND, + RESUME_COMMAND, ] if is_buddy_command_enabled(): cmds.append(BUDDY_COMMAND) diff --git a/src/command_system/resume_command.py b/src/command_system/resume_command.py new file mode 100644 index 000000000..25107d4d9 --- /dev/null +++ b/src/command_system/resume_command.py @@ -0,0 +1,101 @@ +"""resume — ``/resume`` session picker (port of TS local-jsx, components C2). + +TS ``/resume`` (``commands/resume/index.ts``: description "Resume a previous +conversation", argumentHint "[conversation id or search term]") mounts the +``LogSelector`` picker and swaps the live session. Python's interactive swap +lives in the TUI (``tui/commands.py`` → ``open_dialog="resume"`` → +``ResumeConversation`` → ``AgentBridge.resume_session``), because only the +TUI owns a live conversation it can replace. + +This registry command serves the NON-TUI surfaces (REPL/SDK/help/aggregator) +in the **output-style precedent**: ``run()`` returns text without touching +``ctx.ui`` — a degraded-but-honest LIST of resumable sessions plus the +pointer to the TUI for the actual swap. Filtering matches the TUI picker: +metadata-only sessions (``message_count == 0``) are hidden and counted +(gap-doc §5 Q2 decision — headless ``/rename`` can mint such entries). + +Coexistence: **inversion** (the ``/theme`` pattern) — the TUI intercept +stays authoritative; this command never runs there. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .types import ( + CommandContext, + InteractiveCommand, + InteractiveOutcome, +) + + +def _list_resumable(term: str = "") -> tuple[list[str], int]: + """``(lines, hidden_count)`` for the degraded session list. + + UI-neutral by construction: imports only ``services`` modules (no + Textual — the dependency-direction rule from the C1/C2 reviews). + """ + + from src.bootstrap.state import get_session_id + from src.services.session_listing import build_resume_entries, filter_entries + + try: + from src.services.session_storage import SessionStorage + + metas = SessionStorage.list_sessions() + except Exception: + metas = [] + entries, hidden = build_resume_entries( + metas, exclude_session_id=str(get_session_id()) + ) + if term: + entries = filter_entries(entries, term) + return [f"• {entry.label()} [{entry.session_id}]" for entry in entries], hidden + + +@dataclass(frozen=True) +class ResumeCommand(InteractiveCommand): + """List resumable sessions; the interactive swap is TUI-only.""" + + async def run(self, args: str, context: CommandContext) -> InteractiveOutcome: + term = (args or "").strip() + lines, hidden = _list_resumable(term) + if not lines: + message = ( + f"No resumable conversations match {term!r}." + if term + else "No resumable conversations yet." + ) + if hidden: + message += ( + f" ({hidden} metadata-only session(s) hidden — " + "no stored messages.)" + ) + return InteractiveOutcome(message=message, display="system") + header = ( + f"Resumable conversations matching {term!r}:" + if term + else "Resumable conversations:" + ) + parts = [header] + parts.extend(lines) + if hidden: + parts.append( + f"({hidden} unresumable metadata-only session(s) hidden)" + ) + parts.append( + "Resuming replaces the live conversation — run /resume inside " + "the TUI to pick and load one." + ) + return InteractiveOutcome(message="\n".join(parts), display="system") + + +RESUME_COMMAND = ResumeCommand( + name="resume", + description="Resume a previous conversation", # verbatim TS index.ts:6 + argument_hint="[conversation id or search term]", # verbatim TS index.ts:8 + aliases=["continue"], # verbatim TS index.ts:7 +) + + +__all__ = ["RESUME_COMMAND", "ResumeCommand"] diff --git a/src/services/session_listing.py b/src/services/session_listing.py new file mode 100644 index 000000000..19f86b9df --- /dev/null +++ b/src/services/session_listing.py @@ -0,0 +1,114 @@ +"""UI-neutral resumable-session listing (components C2). + +Lives in services — NOT in ``src/tui`` — so the headless ``/resume`` +registry command can list sessions without importing Textual (the C2 +review measured 135 textual modules loaded through the screen-module +import; same dependency-direction rule the C1 review set for +``suggestions_label``). The Textual picker re-exports these names. + +Standing critic condition (gap doc §5 Q2): entries with +``message_count == 0`` are FILTERED out — a headless ``/rename`` can +mint metadata-only sessions that would resume into an empty +conversation — and the hidden count is reported so the list stays +honest. +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass +from typing import Any, Iterable + + +@dataclass(frozen=True) +class ResumeEntry: + """One selectable row: a resumable persisted session.""" + + session_id: str + title: str + message_count: int = 0 + last_updated: float = 0.0 + model: str = "" + + def label(self) -> str: + when = "" + if self.last_updated: + try: + when = time.strftime( + "%Y-%m-%d %H:%M", time.localtime(self.last_updated) + ) + except Exception: + when = "" + parts = [self.title or self.session_id] + meta: list[str] = [] + if when: + meta.append(when) + if self.message_count: + meta.append(f"{self.message_count} msgs") + if self.model: + meta.append(self.model) + if meta: + parts.append(f"({' · '.join(meta)})") + return " ".join(parts) + + +def build_resume_entries( + metas: Iterable[Any], + *, + exclude_session_id: str | None = None, +) -> tuple[list[ResumeEntry], int]: + """Filter raw ``SessionMetadata`` rows into resumable entries. + + Returns ``(entries, hidden_count)`` where ``hidden_count`` is the + number of metadata-only sessions suppressed (``message_count == 0`` + — the §5 Q2 decision). The active session is excluded silently (it + is not "resumable", it is current). Duplicate session ids keep the + first occurrence only — ``list_sessions`` orders by ``last_updated`` + descending, and a duplicated id would crash Textual's OptionList + (DuplicateID). + """ + + entries: list[ResumeEntry] = [] + hidden = 0 + seen: set[str] = set() + for meta in metas: + session_id = getattr(meta, "session_id", None) or getattr(meta, "id", None) + if not session_id: + continue + sid = str(session_id) + if exclude_session_id and sid == exclude_session_id: + continue + if sid in seen: + continue + seen.add(sid) + count = int(getattr(meta, "message_count", 0) or 0) + if count <= 0: + hidden += 1 + continue + entries.append( + ResumeEntry( + session_id=sid, + title=str(getattr(meta, "title", "") or ""), + message_count=count, + last_updated=float(getattr(meta, "last_updated", 0.0) or 0.0), + model=str(getattr(meta, "model", "") or ""), + ) + ) + return entries, hidden + + +def filter_entries(entries: list[ResumeEntry], term: str) -> list[ResumeEntry]: + """Case-insensitive substring filter over id + title (the TS + argumentHint's "search term").""" + + needle = term.strip().lower() + if not needle: + return entries + return [ + e + for e in entries + if needle in e.session_id.lower() or needle in e.title.lower() + ] + + +__all__ = ["ResumeEntry", "build_resume_entries", "filter_entries"] diff --git a/src/tui/agent_bridge.py b/src/tui/agent_bridge.py index 783982735..f3f0c31ba 100644 --- a/src/tui/agent_bridge.py +++ b/src/tui/agent_bridge.py @@ -138,6 +138,81 @@ def reset_advisor_dedup(self) -> None: def busy(self) -> bool: return self._busy + def resume_session(self, session_id: str) -> list[Any] | None: + """Swap the live conversation to a persisted session (C2 /resume). + + Mirrors TS resume semantics: the picked session BECOMES the active + session — its id is installed via ``bootstrap.state.switch_session`` + (the designated resume path, fires ``session_switched``), the + persister re-targets it so subsequent turns append to the SAME + store, and the in-memory conversation is replaced by the stored + transcript. Returns the loaded typed messages for the UI to + re-render, or ``None`` when refused (worker busy) / nothing + stored. Must be called from the UI thread while idle — the guard + is the same ``_busy_lock`` gate ``submit`` uses. + """ + + with self._busy_lock: + if self._busy: + return None + + from src.services.session_persistence import SessionPersister + from src.services.session_resume import resume_session + + # The full TS-parity reader: malformed-line recovery, orphaned + # tool_use repair, snip boundaries, cross-project path + # adjustment (session/resume.ts). Synchronous on the UI thread + # — acceptable for the degraded C2 scope (TS reads async); + # revisit if multi-MB transcripts make the freeze noticeable. + try: + result = resume_session(session_id, current_cwd=os.getcwd()) + except Exception: + # Unreadable transcript (permissions, dir-shaped file…) + # must refuse, not crash the Textual callback chain. + return None + if not result.success or not result.messages: + return None + messages = result.messages + + from src.bootstrap.state import switch_session + from src.services.cost_restore import restore_cost_state_for_session + + # TS ResumeConversation.tsx:224-227: switchSession then + # restoreCostStateForSession, in lockstep. (TS also passes the + # session's project dir to switchSession; nothing consumes + # get_session_project_dir() in Python yet, so it is omitted.) + switch_session(session_id) + # Best-effort: TUI-born sessions don't write the flat cost + # snapshot yet, so this is usually a no-op today; it exists so + # resume of snapshot-bearing sessions restores accumulators. + restore_cost_state_for_session(session_id) + + conversation = self._session.conversation + conversation.messages.clear() + conversation.messages.extend(messages) + self._session.session_id = session_id + + # Advisor dedup: clear the emitted-ID set (old IDs are gone), + # but point the scan cursor at the END of the repopulated list + # — index 0 (the /clear semantics of reset_advisor_dedup) + # would make the first post-resume scan re-emit every + # HISTORICAL advisor event as fresh UI rows. + self._emitted_advisor_ids.clear() + self._last_scanned_msg_index = len(conversation.messages) + + # Re-target persistence; start() only initializes metadata when + # absent, so the resumed session's existing metadata (title, + # counts) is preserved. NOTE: AppState.usage token counters are + # deliberately NOT reset/hydrated here — the status line keeps + # counting from the live process (decision recorded in the C2 + # review; revisit with the C3 context/status work). + self._persister = SessionPersister(session_id=session_id) + self._persister.start( + model=getattr(self._provider, "model", "") or "", + cwd=os.getcwd(), + ) + return list(messages) + def submit(self, prompt: str) -> bool: """Queue ``prompt`` for the agent. Returns False if busy.""" diff --git a/src/tui/app.py b/src/tui/app.py index 640ebb587..4a194b230 100644 --- a/src/tui/app.py +++ b/src/tui/app.py @@ -416,9 +416,105 @@ def _open_phase2_dialog(self, name: str, transcript: Transcript) -> None: self._open_tasks_dialog(transcript) elif name == "workflows": self._open_workflows_dialog(transcript) + elif name == "resume": + self._open_resume_picker(transcript) else: transcript.append_system(f"Dialog '{name}' not available.", style="muted") + def _open_resume_picker(self, transcript: Transcript) -> None: + """C2: list persisted sessions; selection swaps the live session. + + Refuses while the agent is busy (the bridge would also refuse — + this just gives the honest message before pushing a modal). + """ + + if self._agent_bridge.busy: + transcript.append_system( + "Cannot resume while the agent is working — try again when idle.", + style="muted", + ) + return + + from src.bootstrap.state import get_session_id + from src.services.session_storage import SessionStorage + from src.tui.screens.resume_conversation import ( + ResumeConversation, + build_resume_entries, + ) + + try: + metas = SessionStorage.list_sessions() + except Exception: + metas = [] + entries, hidden = build_resume_entries( + metas, exclude_session_id=str(get_session_id()) + ) + + def _on_selected(session_id: str | None) -> None: + if not session_id: + return + loaded = self._agent_bridge.resume_session(session_id) + if loaded is None: + transcript.append_system( + f"Could not resume {session_id}: no stored messages " + "(or the agent became busy).", + style="muted", + ) + return + self._render_resumed_messages(transcript, session_id, loaded) + + self.push_screen( + ResumeConversation(entries=entries, hidden_count=hidden), + callback=_on_selected, + ) + + def _render_resumed_messages( + self, transcript: Transcript, session_id: str, messages: list[Any] + ) -> None: + """Replay a resumed transcript into the view, degraded-but-honest. + + Text content renders as normal user/assistant rows; non-text + blocks (tool calls/results, thinking) are counted and summarized + in one system row rather than fully re-rendered — the live + renderers are event-driven and replaying tool events would + re-trigger activity widgets for work that is not running. + """ + + transcript.clear_transcript() + skipped_blocks = 0 + for message in messages: + role = getattr(message, "role", "") + content = getattr(message, "content", "") + if isinstance(content, str): + text = content + else: + parts: list[str] = [] + for block in content or []: + # The resume reader yields DATACLASS blocks (TextBlock + # et al., types/content_blocks.py) for known types and + # plain dicts for unknown ones — handle both shapes. + if isinstance(block, dict): + block_type = block.get("type") + block_text = block.get("text", "") + else: + block_type = getattr(block, "type", None) + block_text = getattr(block, "text", "") + if block_type == "text": + parts.append(str(block_text)) + else: + skipped_blocks += 1 + text = "\n".join(p for p in parts if p) + if not text: + continue + if role == "user": + transcript.append_user(text) + elif role == "assistant": + transcript.append_assistant(text) + note = f"Resumed session {session_id} ({len(messages)} messages)" + if skipped_blocks: + note += f"; {skipped_blocks} tool/attachment block(s) not re-rendered" + transcript.append_system(note, style="muted") + def _open_workflows_dialog(self, transcript: Transcript) -> None: registry = getattr(self.tool_context, "runtime_tasks", None) if registry is None: diff --git a/src/tui/commands.py b/src/tui/commands.py index 7839b7ac9..e348a138d 100644 --- a/src/tui/commands.py +++ b/src/tui/commands.py @@ -53,6 +53,8 @@ "/tasks", "/workflows", "/rewind", + # components C2: + "/resume", ) @@ -121,6 +123,7 @@ def slash(self) -> str: "/mcp": "Manage MCP servers", "/tasks": "Browse background tasks", "/rewind": "Rewind conversation to an earlier turn", + "/resume": "Resume a previous conversation", } @@ -292,6 +295,12 @@ def dispatch_local_command( return CommandDispatchResult(handled=True, open_dialog="workflows") if name == "/rewind": return CommandDispatchResult(handled=True, open_dialog="rewind") + if name in ("/resume", "/continue"): + # /continue = TS alias (commands/resume/index.ts:7). Intercepted + # here too so the TUI opens the picker instead of falling through + # to the registry command's headless list. Any argument ("search + # term") is ignored on this surface — the modal IS the picker. + return CommandDispatchResult(handled=True, open_dialog="resume") return CommandDispatchResult(handled=False) diff --git a/src/tui/screens/__init__.py b/src/tui/screens/__init__.py index 11b4f15f5..23160436a 100644 --- a/src/tui/screens/__init__.py +++ b/src/tui/screens/__init__.py @@ -17,6 +17,11 @@ from .model_picker import ModelPickerScreen from .permission_modal import PermissionModal from .repl import REPLScreen +from .resume_conversation import ( + ResumeConversation, + ResumeEntry, + build_resume_entries, +) from .theme_picker import ThemePickerScreen __all__ = [ @@ -37,7 +42,10 @@ "ModelPickerScreen", "PermissionModal", "REPLScreen", + "ResumeConversation", + "ResumeEntry", "ThemePickerScreen", "TranscriptMessage", + "build_resume_entries", "fuzzy_score", ] diff --git a/src/tui/screens/resume_conversation.py b/src/tui/screens/resume_conversation.py index 61df4119b..a66c11caa 100644 --- a/src/tui/screens/resume_conversation.py +++ b/src/tui/screens/resume_conversation.py @@ -1,14 +1,30 @@ """Resume-conversation modal screen. -Phase-8 of the ch13 refactor (gap #9) — see -``my-docs/ch13-phase8-audit-result.md`` for the audit result and the -rationale for shipping a placeholder rather than the full wiring. - -When the WI-8.0 audit found state (2) — `services/session_storage.py` -exists as a module but no live caller writes transcripts — the -Resume/Doctor screens were scope-limited to navigation surfaces that -honestly say "not yet wired" to the user. This module provides that -surface; future work fills in the listing once persistence is wired. +C2 of the components-folder parity plan upgraded this from the Phase-8 +placeholder (which honestly said "not yet wired" while the persistence +producer didn't exist) to the real picker: the session-persistence +producer landed in PR #260 (``services/session_persistence`` driven by +``agent_bridge``), so ``SessionStorage.list_sessions()`` now has real +rows to show. Mirrors the degraded scope of TS +``components/LogSelector.tsx`` + ``screens/ResumeConversation.tsx``: +list + select → resume. (TS deep-search / worktree filters / tag tabs +ride on parked subsystems — gap doc §3.2 T2.) + +Standing critic condition (gap doc §5 Q2): entries with +``message_count == 0`` or no transcript are FILTERED out — a headless +``/rename`` can create metadata-only sessions that would resume into an +empty conversation — and a footer reports how many were hidden. + +The screen takes its entries by CONSTRUCTOR (the app does the listing +and filtering) so it stays a pure, testable view. The entry model + +filtering live in the UI-neutral ``src/services/session_listing`` (so +headless ``/resume`` never imports Textual); they are re-exported here +for compatibility. + +Parked (documented divergence): the TS picker shows a transcript +preview pane (``SessionPreview.tsx``); Python's dormant +``widgets/session_preview.py`` is NOT wired in this phase — the picker +ships list+select only, preview rides with a later polish pass. """ from __future__ import annotations @@ -21,13 +37,13 @@ from textual.widgets import OptionList, Static from textual.widgets.option_list import Option +from src.services.session_listing import ResumeEntry, build_resume_entries + class ResumeConversation(ModalScreen[str | None]): - """Modal listing past sessions; dismisses with the chosen session id. + """Modal listing resumable sessions; dismisses with the chosen id. - Currently a placeholder — :meth:`_load_sessions` returns ``[]`` until - transcript-persistence wiring lands. The Esc / Ctrl+C path returns - ``None`` so callers (slash commands) can ignore the dismissal. + Esc / q dismiss with ``None`` so callers can ignore the dismissal. """ BINDINGS = [ @@ -56,8 +72,21 @@ class ResumeConversation(ModalScreen[str | None]): color: $text-muted; padding: 1 0 0 0; } + ResumeConversation Static.-footer { + color: $text-muted; + padding: 1 0 0 0; + } """ + def __init__( + self, + entries: list[ResumeEntry] | None = None, + hidden_count: int = 0, + ) -> None: + super().__init__() + self._entries = list(entries or []) + self._hidden_count = hidden_count + def compose(self) -> ComposeResult: with Middle(): with Center(): @@ -72,26 +101,37 @@ def on_mount(self) -> None: markup=False, ) ) - sessions = self._load_sessions() - if not sessions: + if not self._entries: + empty = "No resumable conversations yet." + if self._hidden_count: + empty += ( + f"\n({self._hidden_count} metadata-only " + "session(s) hidden — no stored messages.)" + ) + body.mount( + Static(Text(empty, style="dim"), classes="-empty", markup=False) + ) + return + options = OptionList( + *( + Option(entry.label(), id=entry.session_id) + for entry in self._entries + ) + ) + body.mount(options) + if self._hidden_count: body.mount( Static( Text( - "No persisted conversations yet.\n\n" - "Transcript persistence is not wired into this build " - "(see my-docs/ch13-phase8-audit-result.md). When the " - "wiring lands, prior sessions will appear here.", + f"{self._hidden_count} unresumable (metadata-only) " + "session(s) hidden", style="dim", ), - classes="-empty", + classes="-footer", markup=False, ) ) - return - options = OptionList( - *(Option(label, id=session_id) for session_id, label in sessions) - ) - body.mount(options) + options.focus() def on_option_list_option_selected( self, event: OptionList.OptionSelected @@ -101,36 +141,5 @@ def on_option_list_option_selected( def action_dismiss_modal(self) -> None: self.dismiss(None) - # ---- internals ---- - def _load_sessions(self) -> list[tuple[str, str]]: - """Return ``(session_id, label)`` pairs to display. - - Reads the metadata files the session-persistence producer - (``services/session_persistence.SessionPersister``, driven by - ``agent_bridge``) writes during normal TUI operation. Empty list - when no sessions exist (clean install, first run). - """ - - try: - from src.services.session_storage import SessionStorage - - metas = SessionStorage.list_sessions() - out: list[tuple[str, str]] = [] - for meta in metas: - session_id = getattr(meta, "session_id", None) or getattr( - meta, "id", None - ) - label = ( - getattr(meta, "title", None) - or getattr(meta, "summary", None) - or session_id - or "(unnamed session)" - ) - if session_id: - out.append((session_id, str(label))) - return out - except Exception: - return [] - -__all__ = ["ResumeConversation"] +__all__ = ["ResumeConversation", "ResumeEntry", "build_resume_entries"] diff --git a/tests/tui/test_resume_doctor_screens.py b/tests/tui/test_resume_doctor_screens.py index 9807853af..de5ba1955 100644 --- a/tests/tui/test_resume_doctor_screens.py +++ b/tests/tui/test_resume_doctor_screens.py @@ -34,11 +34,12 @@ async def on_mount(self) -> None: async def test_resume_screen_shows_empty_state_when_no_sessions( tmp_path, monkeypatch ) -> None: - """Phase-8 placeholder: no sessions → empty-state surface. + """No entries injected → empty-state surface. - Isolate the global ``SESSIONS_DIR`` so this test doesn't pick up - sessions from the dev's real ``~/.clawcodex/sessions/`` (Phase-8 - wiring made the screen actually read from disk). + Since C2 the screen is a pure view (entries arrive via the + constructor; the APP does the disk listing), so the default + construction always shows the empty state. The SESSIONS_DIR + monkeypatch is retained only as belt-and-braces hermeticity. """ monkeypatch.setattr( diff --git a/tests/tui/test_resume_picker.py b/tests/tui/test_resume_picker.py new file mode 100644 index 000000000..4dd5532b8 --- /dev/null +++ b/tests/tui/test_resume_picker.py @@ -0,0 +1,329 @@ +"""C2 resume-picker tests: entry filtering, screen selection, bridge swap.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +import pytest + +pytest.importorskip("textual") + +from textual.app import App, ComposeResult +from textual.screen import Screen +from textual.widgets import Static + +from src.services.session_persistence import SessionPersister +from src.services.session_storage import SessionMetadata, SessionStorage +from src.services.session_listing import ( + ResumeEntry, + build_resume_entries, + filter_entries, +) +from src.tui.screens.resume_conversation import ResumeConversation + + +@pytest.fixture +def sessions_dir(tmp_path, monkeypatch): + base = tmp_path / "sessions" + import src.services.session_storage as storage_mod + + monkeypatch.setattr(storage_mod, "SESSIONS_DIR", base) + return base + + +def _meta(session_id: str, count: int, title: str = "") -> SessionMetadata: + return SessionMetadata( + session_id=session_id, message_count=count, title=title + ) + + +class TestBuildResumeEntries: + def test_filters_zero_count_and_counts_hidden(self) -> None: + entries, hidden = build_resume_entries( + [_meta("a", 3, "alpha"), _meta("b", 0, "ghost"), _meta("c", 1)] + ) + assert [e.session_id for e in entries] == ["a", "c"] + assert hidden == 1 + + def test_excludes_current_session_silently(self) -> None: + entries, hidden = build_resume_entries( + [_meta("current", 5), _meta("other", 2)], + exclude_session_id="current", + ) + assert [e.session_id for e in entries] == ["other"] + assert hidden == 0 + + def test_label_prefers_title_then_id(self) -> None: + titled = ResumeEntry(session_id="x", title="my work", message_count=2) + untitled = ResumeEntry(session_id="y", title="", message_count=2) + assert titled.label().startswith("my work") + assert untitled.label().startswith("y") + + def test_duplicate_session_ids_deduped(self) -> None: + # A duplicated id would crash Textual's OptionList (DuplicateID). + entries, hidden = build_resume_entries( + [_meta("dup", 3, "first"), _meta("dup", 5, "second"), _meta("z", 1)] + ) + assert [e.session_id for e in entries] == ["dup", "z"] + assert entries[0].title == "first" + assert hidden == 0 + + def test_filter_entries_matches_id_and_title(self) -> None: + entries = [ + ResumeEntry(session_id="abc-123", title="fix parser", message_count=1), + ResumeEntry(session_id="def-456", title="docs pass", message_count=1), + ] + assert [e.session_id for e in filter_entries(entries, "parser")] == [ + "abc-123" + ] + assert [e.session_id for e in filter_entries(entries, "def")] == ["def-456"] + assert filter_entries(entries, "") == entries + + +class _Host(Screen): + def compose(self) -> ComposeResult: + yield Static("host") + + +class _DialogHost(App): + def on_mount(self) -> None: + self.push_screen(_Host()) + + +@pytest.mark.asyncio +async def test_screen_selection_returns_session_id() -> None: + import asyncio + + app = _DialogHost() + async with app.run_test() as pilot: + loop = asyncio.get_running_loop() + future: asyncio.Future = loop.create_future() + + def _callback(result): + if not future.done(): + future.set_result(result) + + app.push_screen( + ResumeConversation( + entries=[ + ResumeEntry(session_id="s1", title="one", message_count=2), + ResumeEntry(session_id="s2", title="two", message_count=4), + ], + hidden_count=1, + ), + callback=_callback, + ) + await pilot.pause() + await pilot.press("down") + await pilot.press("enter") + await pilot.pause() + assert await future == "s2" + + +@pytest.mark.asyncio +async def test_screen_escape_returns_none_and_footer_renders() -> None: + import asyncio + + app = _DialogHost() + async with app.run_test() as pilot: + loop = asyncio.get_running_loop() + future: asyncio.Future = loop.create_future() + screen = ResumeConversation( + entries=[ResumeEntry(session_id="s1", title="one", message_count=2)], + hidden_count=3, + ) + app.push_screen(screen, callback=lambda r: future.set_result(r)) + await pilot.pause() + footers = screen.query(".-footer") + assert len(footers) == 1 + await pilot.press("escape") + await pilot.pause() + assert await future is None + + +def _make_bridge(tmp_path): + from src.agent.session import Session + from src.tool_system.context import ToolContext + from src.tool_system.registry import ToolRegistry + from src.tui.agent_bridge import AgentBridge + from src.tui.state import AppState + + session = Session.create("test", "test-model") + bridge = AgentBridge( + post_message=lambda _msg: None, + session=session, + provider=MagicMock(model="test-model"), + tool_registry=ToolRegistry(), + tool_context=ToolContext(workspace_root=tmp_path), + app_state=AppState(), + run_worker=lambda *a, **k: None, + ) + return bridge, session + + +class TestBridgeResumeSession: + def test_round_trip_swaps_conversation_and_persister( + self, sessions_dir, tmp_path + ) -> None: + # Produce a stored session the way production does — including a + # block-list assistant message (the shape the bridge persists). + producer = SessionPersister("old-session", sessions_dir=sessions_dir) + producer.start(model="m", cwd=str(tmp_path)) + producer.record_user("hello from the past") + producer.record( + { + "role": "assistant", + "content": [{"type": "text", "text": "past reply"}], + } + ) + producer.flush() + # Give it a title the way /rename would. + SessionStorage( + session_id="old-session", sessions_dir=sessions_dir + ).update_metadata(title="titled work") + + bridge, session = _make_bridge(tmp_path) + session.conversation.add_user_message("current work") + + loaded = bridge.resume_session("old-session") + assert loaded is not None + assert len(session.conversation.messages) == len(loaded) == 2 + roles = [m.role for m in session.conversation.messages] + assert roles == ["user", "assistant"] + + # Bootstrap id switched (TS switchSession path). + from src.bootstrap.state import get_session_id + + assert str(get_session_id()) == "old-session" + + # Advisor scan cursor points at the END of the repopulated list — + # index 0 would re-emit historical advisor events on the first + # post-resume scan. + assert bridge._last_scanned_msg_index == 2 + assert bridge._emitted_advisor_ids == set() + + # Persister re-targeted: the next user turn appends to the SAME + # store, the title survives, and message_count increments from the + # loaded value (no double counting). + bridge._persister.record_user("new turn after resume") + bridge._persister.flush() + storage = SessionStorage( + session_id="old-session", sessions_dir=sessions_dir + ) + entries = storage.read_transcript() + assert entries[-1]["role"] == "user" + assert "new turn after resume" in str(entries[-1]["content"]) + meta = storage.get_metadata() + assert meta is not None + assert meta.title == "titled work" + assert meta.message_count == 3 + + def test_refuses_while_busy(self, sessions_dir, tmp_path) -> None: + producer = SessionPersister("busy-target", sessions_dir=sessions_dir) + producer.start(model="m", cwd=str(tmp_path)) + producer.record_user("x") + producer.flush() + + bridge, _session = _make_bridge(tmp_path) + bridge._busy = True + assert bridge.resume_session("busy-target") is None + + def test_missing_session_returns_none(self, sessions_dir, tmp_path) -> None: + bridge, session = _make_bridge(tmp_path) + before = list(session.conversation.messages) + assert bridge.resume_session("does-not-exist") is None + assert session.conversation.messages == before + + +class TestRenderResumedMessages: + """The replay renderer must handle DATACLASS content blocks — the + resume reader yields TextBlock et al., not dicts (C2 review B1).""" + + def _render(self, messages): + from src.tui.app import ClawCodexTUI + + rows: list[tuple[str, str]] = [] + + class _FakeTranscript: + def clear_transcript(self): + rows.append(("clear", "")) + + def append_user(self, text): + rows.append(("user", text)) + + def append_assistant(self, text): + rows.append(("assistant", text)) + + def append_system(self, text, style="muted"): + rows.append(("system", text)) + + ClawCodexTUI._render_resumed_messages( + MagicMock(), _FakeTranscript(), "sid", messages + ) + return rows + + def test_dataclass_text_blocks_render(self, sessions_dir, tmp_path) -> None: + # Produce + read through the PRODUCTION pipeline so blocks arrive + # in whatever shape the reader actually yields. + from src.services.session_resume import resume_session as read_back + + producer = SessionPersister("render-me", sessions_dir=sessions_dir) + producer.start(model="m", cwd=str(tmp_path)) + producer.record_user("the question") + producer.record( + { + "role": "assistant", + "content": [ + {"type": "text", "text": "the answer"}, + { + "type": "tool_use", + "id": "t1", + "name": "Bash", + "input": {"command": "ls"}, + }, + ], + } + ) + producer.flush() + result = read_back("render-me") + assert result.success + + rows = self._render(result.messages) + kinds = [k for k, _ in rows] + assert kinds[0] == "clear" + assert ("user", "the question") in rows + assert ("assistant", "the answer") in rows, ( + "dataclass TextBlock content must render" + ) + note = rows[-1][1] + assert "Resumed session sid" in note + # tool_use (and its synthetic orphan-repair tool_result) counted, + # never silently dropped. + assert "not re-rendered" in note + + +class TestResumeRegistryCommand: + @pytest.mark.asyncio + async def test_lists_sessions_headless(self, sessions_dir, tmp_path) -> None: + producer = SessionPersister("listed", sessions_dir=sessions_dir) + producer.start(model="m", cwd=str(tmp_path)) + producer.record_user("x") + producer.flush() + # update message_count metadata + ghost = SessionStorage(session_id="ghost", sessions_dir=sessions_dir) + ghost.init_metadata(title="ghost-entry") + + from src.command_system.resume_command import RESUME_COMMAND + + outcome = await RESUME_COMMAND.run("", MagicMock()) + assert "listed" in outcome.message + assert "metadata-only" in outcome.message + assert "TUI" in outcome.message + + @pytest.mark.asyncio + async def test_empty_store_is_honest(self, sessions_dir) -> None: + from src.command_system.resume_command import RESUME_COMMAND + + outcome = await RESUME_COMMAND.run("", MagicMock()) + assert "No resumable conversations" in outcome.message