From 1c9b8ec056d1afb1c5d0c833028e177f825e740e Mon Sep 17 00:00:00 2001 From: Layne Penney Date: Wed, 26 Aug 2026 10:50:11 -0500 Subject: [PATCH] feat(recall): expose session resume as the recall_resume MCP tool The `synapt resume` CLI answers "where did we leave off" by position (the last N turns of a session) rather than by relevance, pairs the tail with the journal that session wrote, and labels index freshness. Until now it was reachable only from a shell, so an agent starting from inside an MCP runtime had the journal but not the tail, and the two diverge whenever work continued after the journal was written. recall_resume wraps the same build_resume_view / format_resume path the CLI uses, on the bounded resume index (never the full index), and closes the DB it opens. Freshness is attached after the view is built and can only change what the reader is told, never what is shown; a freshness failure renders as NOT CHECKED rather than as fresh. The empty-index and unresolved-session outcomes stay distinguishable, as in the CLI. Registered with the directive check alongside recall_sessions. Tests cover the missing-index, honest-empty, unresolved-session, argument pass-through, freshness-failure, and registration paths. Co-Authored-By: Claude --- .codex/skills/synapt/SKILL.md | 1 + src/synapt/recall/server.py | 74 ++++++++++++++++++++ tests/recall/test_server_resume.py | 106 +++++++++++++++++++++++++++++ 3 files changed, 181 insertions(+) create mode 100644 tests/recall/test_server_resume.py diff --git a/.codex/skills/synapt/SKILL.md b/.codex/skills/synapt/SKILL.md index fd4c5c86..7957241b 100644 --- a/.codex/skills/synapt/SKILL.md +++ b/.codex/skills/synapt/SKILL.md @@ -24,6 +24,7 @@ recall_quick recall_search recall_context recall_sessions +recall_resume recall_files ``` diff --git a/src/synapt/recall/server.py b/src/synapt/recall/server.py index f50ccbac..ea0b3b85 100644 --- a/src/synapt/recall/server.py +++ b/src/synapt/recall/server.py @@ -565,6 +565,79 @@ def recall_sessions( return "\n".join(lines) +def recall_resume( + session_id: str | None = None, + turns: int = 10, +) -> str: + """Show the tail of the most recent session so a fresh session can pick up where it stopped. + + Answers by POSITION (the last N turns), not by relevance -- which is what a + cold "where did we leave off" needs and what search cannot give. Pairs the + tail with the journal entry that session wrote, if any, and labels the + index-freshness verdict so a stale view is never mistaken for a complete one. + + Use at session start, alongside recall_journal: the journal is what the + previous session chose to write, this is what actually happened last. They + diverge whenever work continued after the journal was written. + + Same surface as the `synapt resume` CLI. + + Args: + session_id: Session to resume (prefix accepted). Default: the newest session. + turns: Number of tail turns to show (default 10). + """ + from synapt.recall.journal import _journal_path + from synapt.recall.resume import ( + ResumeError, + build_resume_view, + format_resume, + load_resume_index, + ) + from synapt.recall.sharding import is_sharded + + index_dir = project_index_dir() + if ( + not (index_dir / "recall.db").exists() + and not (index_dir / "chunks.jsonl").exists() + and not is_sharded(index_dir) + ): + return f"No index found at {index_dir}. Run `synapt recall setup` first." + + index = load_resume_index(index_dir) + try: + try: + view = build_resume_view( + index, + session_id=session_id, + limit=turns, + journal_path=_journal_path(), + ) + except ResumeError as exc: + if not index._session_order: + return "No sessions indexed yet. Nothing to resume." + return f"Resume failed: {exc}" + finally: + db = getattr(index, "_db", None) + if db is not None: + db.close() + + # Freshness is attached after the view is built (same contract as the CLI): + # it can only change what the reader is told, never what is shown. A failure + # to compute it leaves the verdict None, rendered as NOT CHECKED, not fresh. + try: + import dataclasses + + from synapt.recall.freshness import check_index_freshness + + result = check_index_freshness(None, index_dir=index_dir) + if not result.stale and not view.turns: + result = check_index_freshness(None, index_dir=index_dir, deep=True) + view = dataclasses.replace(view, freshness=result) + except Exception: + pass + + return format_resume(view) + def recall_build(incremental: bool = True) -> str: """Build or rebuild the transcript index from auto-discovered sources. @@ -2419,6 +2492,7 @@ def register_tools(mcp) -> None: mcp.tool()(_with_directive_check(recall_quick)) mcp.tool()(_with_directive_check(recall_files)) mcp.tool()(_with_directive_check(recall_sessions)) + mcp.tool()(_with_directive_check(recall_resume)) mcp.tool()(recall_build) mcp.tool()(recall_setup) mcp.tool()(recall_export) diff --git a/tests/recall/test_server_resume.py b/tests/recall/test_server_resume.py new file mode 100644 index 00000000..b897208b --- /dev/null +++ b/tests/recall/test_server_resume.py @@ -0,0 +1,106 @@ +"""recall_resume: the `synapt resume` surface exposed as an MCP tool. + +Mirrors test_server_sessions.py: the tool must use the bounded resume index, +never construct the full index, and always close the DB it opened. +""" + +from unittest.mock import Mock, patch + + +def test_recall_resume_reports_missing_index(monkeypatch, tmp_path): + from synapt.recall import server + + monkeypatch.setattr(server, "project_index_dir", lambda: tmp_path) + out = server.recall_resume() + assert out.startswith("No index found at") + assert "synapt recall setup" in out + + +def test_recall_resume_empty_index_is_honest_empty_not_error(monkeypatch, tmp_path): + from synapt.recall import server + from synapt.recall.resume import ResumeError + + index = Mock() + index._session_order = [] + index._db = Mock() + (tmp_path / "recall.db").touch() + monkeypatch.setattr(server, "project_index_dir", lambda: tmp_path) + with ( + patch("synapt.recall.journal._journal_path", return_value=tmp_path / "journal.jsonl"), + patch("synapt.recall.resume.load_resume_index", return_value=index) as load, + patch("synapt.recall.resume.build_resume_view", side_effect=ResumeError("nothing")), + patch.object( + server, + "_get_index", + side_effect=AssertionError("resume constructed the full index"), + ), + ): + assert server.recall_resume() == "No sessions indexed yet. Nothing to resume." + + load.assert_called_once_with(tmp_path) + index._db.close.assert_called_once_with() + + +def test_recall_resume_unresolved_session_is_an_error_not_empty(monkeypatch, tmp_path): + from synapt.recall import server + from synapt.recall.resume import ResumeError + + index = Mock() + index._session_order = ["abc"] + index._db = Mock() + (tmp_path / "recall.db").touch() + monkeypatch.setattr(server, "project_index_dir", lambda: tmp_path) + with ( + patch("synapt.recall.journal._journal_path", return_value=tmp_path / "journal.jsonl"), + patch("synapt.recall.resume.load_resume_index", return_value=index), + patch("synapt.recall.resume.build_resume_view", side_effect=ResumeError("no such session zzz")), + ): + out = server.recall_resume(session_id="zzz") + + assert out == "Resume failed: no such session zzz" + index._db.close.assert_called_once_with() + + +def test_recall_resume_passes_session_and_turns_and_renders(monkeypatch, tmp_path): + from synapt.recall import server + + index = Mock() + index._session_order = ["abc"] + index._db = Mock() + view = Mock() + view.turns = [object()] + (tmp_path / "recall.db").touch() + monkeypatch.setattr(server, "project_index_dir", lambda: tmp_path) + with ( + patch("synapt.recall.journal._journal_path", return_value=tmp_path / "journal.jsonl"), + patch("synapt.recall.resume.load_resume_index", return_value=index), + patch("synapt.recall.resume.build_resume_view", return_value=view) as build, + patch("synapt.recall.freshness.check_index_freshness", side_effect=RuntimeError("no fs")), + patch("synapt.recall.resume.format_resume", return_value="RENDERED") as fmt, + ): + assert server.recall_resume(session_id="ab", turns=3) == "RENDERED" + + kwargs = build.call_args.kwargs + assert kwargs["session_id"] == "ab" + assert kwargs["limit"] == 3 + assert kwargs["journal_path"] == tmp_path / "journal.jsonl" + # Freshness failure must not break the tool: the view renders unchanged. + fmt.assert_called_once_with(view) + index._db.close.assert_called_once_with() + + +def test_recall_resume_is_registered_with_directive_check(): + from synapt.recall import server + + registered = [] + + class FakeMCP: + def tool(self): + def deco(fn): + registered.append(getattr(fn, "__name__", repr(fn))) + return fn + return deco + + server.register_tools(FakeMCP()) + assert "recall_resume" in registered + assert "recall_sessions" in registered