smriti/cli/tests/test_attachment.py
Himanshu Dongre 68b3b51661 Attach projects to spaces so sessions stay connected automatically
Smriti previously required passing <space> on every command, and the
repo↔space binding lived only inside a hardcoded SessionStart hook
string. This makes the binding durable and ambient.

- New cli/smriti_cli/attachment.py reads/writes `.smriti.json` at the
  repo root — a committed file that binds the repo to a space and
  travels with it (repo A→space A, repo B→space B).
- The <space> positional is now optional on state, current, metrics,
  checkpoint create/list, claim create/list, and worktree open/list.
  When omitted it resolves from the nearest `.smriti.json`, walking up
  from the working directory, so any session opened inside the repo
  stays connected without re-passing the space.
- `smriti init` writes `.smriti.json` and binds the space's backend
  project_root to the checkout. The generated SessionStart hook is now
  space-less (`smriti state --compact`) — identical across every
  project and stable when a project is re-attached.
- New `smriti status` command: a day-two continuity check showing the
  attachment, backend health, and open work.
- CLI api_url also resolves from the attachment as a last-resort
  default, behind --api-url and SMRITI_API_URL.

MCP tool signatures are intentionally unchanged this sprint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 00:10:50 +05:30

255 lines
9 KiB
Python

"""Tests for project attachment — the durable repo ↔ space binding.
Covers:
- The attachment module: find / load / write `.smriti.json`
- CLI space resolution: explicit arg vs. attachment fallback
- CLI api_url resolution precedence
- `smriti status` output, attached and unattached
- `<space>` is optional on the parser for attachment-aware commands
"""
from __future__ import annotations
import argparse
import json
from unittest.mock import MagicMock
import pytest
from smriti_cli import attachment
from smriti_cli import main as cli_main
from smriti_cli.attachment import (
Attachment,
AttachmentError,
find_attachment_file,
load_attachment,
write_attachment,
)
from smriti_cli.client import SmritiClient, SmritiError
# ── attachment module ───────────────────────────────────────────────────────
def test_write_then_load_roundtrip(tmp_path):
write_attachment(tmp_path, "my-space", api_url="http://localhost:8000")
loaded = load_attachment(tmp_path)
assert loaded is not None
assert loaded.space == "my-space"
assert loaded.api_url == "http://localhost:8000"
assert loaded.root == tmp_path.resolve()
def test_write_attachment_records_timestamp(tmp_path):
path = write_attachment(tmp_path, "s")
data = json.loads(path.read_text())
assert "attached_at" in data
assert data["attached_at"].endswith("Z")
def test_write_attachment_preserves_unknown_keys(tmp_path):
path = tmp_path / attachment.ATTACHMENT_FILENAME
path.write_text(json.dumps({"space": "old", "future_key": "keep-me"}))
write_attachment(tmp_path, "new-space")
data = json.loads(path.read_text())
assert data["space"] == "new-space"
assert data["future_key"] == "keep-me"
def test_find_attachment_walks_up_to_parent(tmp_path):
write_attachment(tmp_path, "root-space")
nested = tmp_path / "a" / "b" / "c"
nested.mkdir(parents=True)
found = find_attachment_file(nested)
assert found == (tmp_path / attachment.ATTACHMENT_FILENAME)
def test_find_attachment_returns_none_when_absent(tmp_path):
assert find_attachment_file(tmp_path) is None
def test_load_attachment_returns_none_when_absent(tmp_path):
assert load_attachment(tmp_path) is None
def test_load_attachment_rejects_malformed_json(tmp_path):
(tmp_path / attachment.ATTACHMENT_FILENAME).write_text("{not json")
with pytest.raises(AttachmentError):
load_attachment(tmp_path)
def test_load_attachment_rejects_missing_space(tmp_path):
(tmp_path / attachment.ATTACHMENT_FILENAME).write_text(json.dumps({"api_url": "x"}))
with pytest.raises(AttachmentError):
load_attachment(tmp_path)
def test_load_attachment_rejects_non_object(tmp_path):
(tmp_path / attachment.ATTACHMENT_FILENAME).write_text(json.dumps(["not", "a", "dict"]))
with pytest.raises(AttachmentError):
load_attachment(tmp_path)
def test_attachment_blank_space_is_treated_as_missing(tmp_path):
att = Attachment(tmp_path / ".smriti.json", {"space": " "})
assert att.space is None
# ── CLI space resolution ────────────────────────────────────────────────────
def test_resolve_space_name_prefers_explicit_arg(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
write_attachment(tmp_path, "attached-space")
args = argparse.Namespace(space="explicit-space")
assert cli_main._resolve_space_name(args) == "explicit-space"
def test_resolve_space_name_falls_back_to_attachment(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
write_attachment(tmp_path, "attached-space")
args = argparse.Namespace(space=None)
assert cli_main._resolve_space_name(args) == "attached-space"
def test_resolve_space_name_fails_without_arg_or_attachment(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
args = argparse.Namespace(space=None)
with pytest.raises(SystemExit):
cli_main._resolve_space_name(args)
def test_resolve_space_name_fails_loudly_on_corrupt_attachment(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
(tmp_path / attachment.ATTACHMENT_FILENAME).write_text("{broken")
args = argparse.Namespace(space=None)
with pytest.raises(SystemExit):
cli_main._resolve_space_name(args)
# ── CLI api_url resolution ──────────────────────────────────────────────────
def test_resolve_api_url_prefers_explicit_flag(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("SMRITI_API_URL", raising=False)
write_attachment(tmp_path, "s", api_url="http://attachment:9000")
args = argparse.Namespace(api_url="http://flag:1234")
assert cli_main._resolve_api_url(args) == "http://flag:1234"
def test_resolve_api_url_defers_to_env_var(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("SMRITI_API_URL", "http://env:5555")
write_attachment(tmp_path, "s", api_url="http://attachment:9000")
args = argparse.Namespace(api_url=None)
# Returns None so SmritiClient itself reads the env var.
assert cli_main._resolve_api_url(args) is None
def test_resolve_api_url_falls_back_to_attachment(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("SMRITI_API_URL", raising=False)
write_attachment(tmp_path, "s", api_url="http://attachment:9000")
args = argparse.Namespace(api_url=None)
assert cli_main._resolve_api_url(args) == "http://attachment:9000"
def test_resolve_api_url_none_when_nothing_set(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("SMRITI_API_URL", raising=False)
args = argparse.Namespace(api_url=None)
assert cli_main._resolve_api_url(args) is None
# ── parser: <space> is optional for attachment-aware commands ────────────────
@pytest.mark.parametrize(
"argv",
[
["state"],
["current"],
["claim", "list"],
["worktree", "list"],
["metrics"],
["checkpoint", "list"],
],
)
def test_space_positional_is_optional(argv):
args = cli_main._build_parser().parse_args(argv)
assert args.space is None
def test_space_positional_still_accepts_explicit_value():
args = cli_main._build_parser().parse_args(["state", "my-space"])
assert args.space == "my-space"
# ── smriti status ───────────────────────────────────────────────────────────
def _status_client(reachable=True):
client = MagicMock(spec=SmritiClient)
client.base_url = "http://localhost:8000"
if reachable:
client.get_health.return_value = {"status": "ok"}
else:
client.get_health.side_effect = SmritiError("unreachable")
return client
def test_status_unattached(tmp_path, monkeypatch, capsys):
monkeypatch.chdir(tmp_path)
client = _status_client()
args = argparse.Namespace(json=True)
cli_main.cmd_status(client, args)
report = json.loads(capsys.readouterr().out)
assert report["attached"] is False
assert report["space"] is None
assert report["backend_reachable"] is True
def test_status_attached_reports_space_and_claims(tmp_path, monkeypatch, capsys):
monkeypatch.chdir(tmp_path)
write_attachment(tmp_path, "bound-space")
client = _status_client()
client.resolve_space.return_value = {
"id": "space-uuid",
"name": "bound-space",
"project_root": str(tmp_path),
}
client.list_claims.return_value = [
{"intent_type": "implement", "scope": "do a thing", "agent": "claude-code"}
]
client.get_head.return_value = {"commit_id": "commit-1"}
args = argparse.Namespace(json=True)
cli_main.cmd_status(client, args)
report = json.loads(capsys.readouterr().out)
assert report["attached"] is True
assert report["space"] == "bound-space"
assert report["space_resolved"]["id"] == "space-uuid"
assert len(report["active_claims"]) == 1
assert report["head_commit_id"] == "commit-1"
def test_status_attached_but_backend_down(tmp_path, monkeypatch, capsys):
monkeypatch.chdir(tmp_path)
write_attachment(tmp_path, "bound-space")
client = _status_client(reachable=False)
args = argparse.Namespace(json=True)
cli_main.cmd_status(client, args)
report = json.loads(capsys.readouterr().out)
assert report["attached"] is True
assert report["backend_reachable"] is False
assert report["space_resolved"] is None
def test_status_corrupt_attachment_is_reported(tmp_path, monkeypatch, capsys):
monkeypatch.chdir(tmp_path)
(tmp_path / attachment.ATTACHMENT_FILENAME).write_text("{broken")
client = _status_client()
args = argparse.Namespace(json=True)
cli_main.cmd_status(client, args)
report = json.loads(capsys.readouterr().out)
assert report["attached"] is False
assert report["attach_error"] is not None