Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .codex/skills/synapt/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ recall_quick
recall_search
recall_context
recall_sessions
recall_resume
recall_files
```

Expand Down
74 changes: 74 additions & 0 deletions src/synapt/recall/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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)
Expand Down
106 changes: 106 additions & 0 deletions tests/recall/test_server_resume.py
Original file line number Diff line number Diff line change
@@ -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
Loading