mirror of
https://github.com/himanshudongre/smriti.git
synced 2026-09-07 08:26:25 +00:00
The repo-state section added by the previous commit shows working-tree and git-upstream drift. It does not answer the question Smriti's trust story actually needs: has the repo moved since the last checkpoint? Record git provenance on checkpoints and compare against it. The CLI captures the git HEAD and branch when it creates a checkpoint; the V4 commit endpoint stores that under the commit's context_blob and returns it on read. `smriti state` then compares the working repo against the latest checkpoint's recorded HEAD/branch and surfaces: - repo unchanged since the last checkpoint - repo is N commit(s) ahead — recorded state may be stale - repo history has diverged from the last checkpoint - the last checkpoint was taken on a different branch All local and read-only: no fetch, no reconciliation. Checkpoints made before this feature (or by the MCP server) record no git state and are simply left uncompared.
1145 lines
39 KiB
Python
1145 lines
39 KiB
Python
"""Formatter tests for multi-branch state rendering + MCP wiring tests
|
|
for `smriti_state` main_only and default paths.
|
|
|
|
These tests cover the digestibility guarantees the build rests on:
|
|
|
|
- Passing `space_state=None` to `format_state_brief` produces output
|
|
byte-for-byte identical to the pre-multi-branch version.
|
|
- Passing a space_state with empty active_branches and no divergence
|
|
produces no "Active branches" or "Divergence signal" sections.
|
|
- Active branches render as a concise one-liner per branch.
|
|
- Divergence signal renders only when pairs are present and names the
|
|
specific conflicting decisions.
|
|
- MCP `smriti_state` calls `get_space_state` by default and the legacy
|
|
`get_head` + `get_commit` path only when `main_only=True`.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from smriti_cli import main as cli_main
|
|
from smriti_cli import mcp_server
|
|
from smriti_cli.formatters import (
|
|
_format_active_branches_section,
|
|
_format_divergence_signal_section,
|
|
_format_repo_state_section,
|
|
_relative_time,
|
|
_task_section,
|
|
_normalize_task_item,
|
|
format_state_brief,
|
|
)
|
|
|
|
|
|
# ── Fixtures: canonical space / head / commit dicts ─────────────────────────
|
|
|
|
|
|
def _base_space():
|
|
return {
|
|
"id": "space-uuid",
|
|
"name": "my-project",
|
|
"description": "Test project.",
|
|
}
|
|
|
|
|
|
def _base_head(commit_id="commit-uuid"):
|
|
return {
|
|
"repo_id": "space-uuid",
|
|
"commit_hash": "abcdef1234567890",
|
|
"commit_id": commit_id,
|
|
"summary": "Current state summary.",
|
|
"objective": "Build the skill pack.",
|
|
"latest_session_id": "session-uuid",
|
|
"latest_session_title": "work session",
|
|
}
|
|
|
|
|
|
def _base_commit():
|
|
return {
|
|
"id": "commit-uuid",
|
|
"repo_id": "space-uuid",
|
|
"commit_hash": "abcdef1234567890",
|
|
"branch_name": "main",
|
|
"author_agent": "claude-code",
|
|
"project_root": "/tmp/test-project",
|
|
"message": "Base",
|
|
"summary": "Main summary here.",
|
|
"objective": "Build something",
|
|
"decisions": ["Decision A"],
|
|
"assumptions": ["Assumption X"],
|
|
"tasks": ["Task 1"],
|
|
"open_questions": [],
|
|
"entities": ["Pydantic"],
|
|
"artifacts": [],
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
|
|
|
|
# ── Formatter-level tests ────────────────────────────────────────────────────
|
|
|
|
|
|
def test_relative_time_handles_aware_and_naive_iso_timestamps():
|
|
two_hours_ago = datetime.now(timezone.utc) - timedelta(hours=2)
|
|
|
|
assert _relative_time(two_hours_ago.isoformat()) == "2h ago"
|
|
assert _relative_time(two_hours_ago.replace(tzinfo=None).isoformat()) == "2h ago"
|
|
|
|
|
|
def test_format_state_brief_no_space_state_matches_pre_build():
|
|
"""With space_state=None, output must not contain any multi-branch sections.
|
|
This is the regression guard for existing CLI callers."""
|
|
space = _base_space()
|
|
head = _base_head()
|
|
commit = _base_commit()
|
|
|
|
out = format_state_brief(space, head, commit, space_state=None)
|
|
|
|
assert "## Active branches" not in out
|
|
assert "## Divergence signal" not in out
|
|
# Core sections still present.
|
|
assert "# my-project" in out
|
|
assert "## Current objective" in out
|
|
assert "## Decisions" in out
|
|
|
|
|
|
def test_format_state_brief_empty_branches_and_no_divergence_elided():
|
|
"""space_state present but empty → same output as space_state=None."""
|
|
space = _base_space()
|
|
head = _base_head()
|
|
commit = _base_commit()
|
|
|
|
empty_state = {"active_branches": [], "divergence": None}
|
|
out = format_state_brief(space, head, commit, space_state=empty_state)
|
|
|
|
assert "## Active branches" not in out
|
|
assert "## Divergence signal" not in out
|
|
|
|
|
|
def test_format_state_brief_shows_canonical_project_root():
|
|
space = _base_space()
|
|
space["project_root"] = "/tmp/canonical-project"
|
|
commit = _base_commit()
|
|
|
|
out = format_state_brief(space, _base_head(), commit)
|
|
|
|
assert "Project root: /tmp/canonical-project" in out
|
|
assert "Latest checkpoint:" in out
|
|
assert "at `/tmp/test-project`" in out
|
|
|
|
|
|
def test_format_state_brief_omits_project_root_line_when_null():
|
|
space = _base_space()
|
|
space["project_root"] = None
|
|
|
|
out = format_state_brief(space, _base_head(), _base_commit())
|
|
|
|
assert "Project root:" not in out
|
|
assert "at `/tmp/test-project`" in out
|
|
|
|
|
|
def test_format_state_brief_active_branches_only():
|
|
"""Non-empty active_branches → Active branches section appears,
|
|
Divergence signal section absent."""
|
|
space = _base_space()
|
|
head = _base_head()
|
|
commit = _base_commit()
|
|
|
|
state = {
|
|
"active_branches": [
|
|
{
|
|
"branch_name": "experiment-a",
|
|
"commit_id": "branch-uuid-1",
|
|
"commit_hash": "11112222" * 5,
|
|
"message": "Trying stdlib",
|
|
"author_agent": "codex-local",
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
"summary": "Fork summary.",
|
|
}
|
|
],
|
|
"divergence": None,
|
|
}
|
|
out = format_state_brief(space, head, commit, space_state=state)
|
|
|
|
assert "## Active branches" in out
|
|
assert "`experiment-a`" in out
|
|
assert "`codex-local`" in out
|
|
assert "Trying stdlib" in out
|
|
assert "## Divergence signal" not in out
|
|
|
|
|
|
def test_format_state_brief_with_divergence():
|
|
"""Active branches + divergence → both sections rendered, specific
|
|
conflicting decisions named."""
|
|
space = _base_space()
|
|
head = _base_head()
|
|
commit = _base_commit()
|
|
|
|
state = {
|
|
"active_branches": [
|
|
{
|
|
"branch_name": "stdlib-fork",
|
|
"commit_id": "branch-uuid",
|
|
"commit_hash": "deadbeef" * 5,
|
|
"message": "Stdlib attempt",
|
|
"author_agent": "codex-local",
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
"summary": "Fork summary.",
|
|
}
|
|
],
|
|
"divergence": {
|
|
"pairs": [
|
|
{
|
|
"branch_name": "stdlib-fork",
|
|
"branch_commit_hash": "deadbeef" * 5,
|
|
"main_only_decisions": ["Use Pydantic with extra=forbid"],
|
|
"branch_only_decisions": [
|
|
"Use stdlib dataclasses",
|
|
"Reject third-party deps",
|
|
],
|
|
}
|
|
]
|
|
},
|
|
}
|
|
out = format_state_brief(space, head, commit, space_state=state)
|
|
|
|
assert "## Active branches" in out
|
|
assert "## Divergence signal" in out
|
|
assert "Use Pydantic with extra=forbid" in out
|
|
assert "Use stdlib dataclasses" in out
|
|
assert "Reject third-party deps" in out
|
|
# Pointer to compare command is in the section header text.
|
|
assert "smriti compare" in out
|
|
|
|
|
|
def test_format_divergence_signal_empty_pairs_elided():
|
|
"""divergence with empty pairs array → helper returns empty string."""
|
|
assert _format_divergence_signal_section(None) == ""
|
|
assert _format_divergence_signal_section({"pairs": []}) == ""
|
|
assert _format_divergence_signal_section({}) == ""
|
|
|
|
|
|
def test_format_active_branches_empty_returns_empty():
|
|
assert _format_active_branches_section([]) == ""
|
|
|
|
|
|
def test_format_repo_state_section_clean_repo():
|
|
out = _format_repo_state_section(
|
|
{
|
|
"git_root": "/tmp/project",
|
|
"branch": "main",
|
|
"detached": False,
|
|
"head_short": "abc1234",
|
|
"dirty_files": 0,
|
|
"untracked_files": 0,
|
|
"ahead": 0,
|
|
"behind": 0,
|
|
"upstream_known": True,
|
|
"project_root": "/tmp/project",
|
|
"project_root_matches": True,
|
|
"signals": [],
|
|
}
|
|
)
|
|
|
|
assert "## Repo state" in out
|
|
assert "branch `main`" in out
|
|
assert "HEAD `abc1234`" in out
|
|
assert "upstream +0/-0" in out
|
|
assert "project root: matches this space" in out
|
|
assert "clean: no local repo drift signals" in out
|
|
|
|
|
|
def test_format_repo_state_section_attention_signals():
|
|
out = _format_repo_state_section(
|
|
{
|
|
"git_root": "/tmp/other",
|
|
"branch": None,
|
|
"detached": True,
|
|
"head_short": "def5678",
|
|
"dirty_files": 2,
|
|
"untracked_files": 1,
|
|
"ahead": 3,
|
|
"behind": 4,
|
|
"upstream_known": True,
|
|
"project_root": "/tmp/project",
|
|
"project_root_matches": False,
|
|
"signals": [
|
|
{
|
|
"severity": "attention",
|
|
"message": "current git root differs from this space's project_root",
|
|
},
|
|
{
|
|
"severity": "attention",
|
|
"message": "2 tracked file(s) have uncommitted changes",
|
|
},
|
|
],
|
|
}
|
|
)
|
|
|
|
assert "branch `detached HEAD`" in out
|
|
assert "upstream +3/-4" in out
|
|
assert "project root: differs from this space" in out
|
|
assert "### Attention" in out
|
|
assert "current git root differs" in out
|
|
assert "uncommitted changes" in out
|
|
|
|
|
|
def test_format_state_brief_appends_repo_state():
|
|
out = format_state_brief(
|
|
_base_space(),
|
|
_base_head(),
|
|
_base_commit(),
|
|
repo_state={
|
|
"git_root": "/tmp/project",
|
|
"branch": "main",
|
|
"detached": False,
|
|
"head_short": "abc1234",
|
|
"upstream_known": False,
|
|
"project_root_matches": None,
|
|
"signals": [],
|
|
},
|
|
)
|
|
|
|
assert "## Repo state" in out
|
|
assert "upstream unknown" in out
|
|
|
|
|
|
def test_build_repo_state_flags_practical_drift(monkeypatch):
|
|
monkeypatch.setattr(
|
|
cli_main,
|
|
"_git_context",
|
|
lambda cwd=None: {
|
|
"git_root": "/tmp/actual",
|
|
"git_sha": "abcdef123456",
|
|
"git_sha_short": "abcdef1",
|
|
"branch": "feature",
|
|
},
|
|
)
|
|
monkeypatch.setattr(cli_main, "_git_ahead_behind", lambda cwd: (2, 1))
|
|
monkeypatch.setattr(cli_main, "_git_dirty_count", lambda cwd: 3)
|
|
monkeypatch.setattr(cli_main, "_git_porcelain_count", lambda cwd, prefix: 1)
|
|
|
|
repo_state = cli_main._build_repo_state({"project_root": "/tmp/expected"})
|
|
|
|
assert repo_state is not None
|
|
assert repo_state["git_root"] == "/tmp/actual"
|
|
assert repo_state["ahead"] == 2
|
|
assert repo_state["behind"] == 1
|
|
assert repo_state["dirty_files"] == 3
|
|
assert repo_state["untracked_files"] == 1
|
|
kinds = {signal["kind"] for signal in repo_state["signals"]}
|
|
assert "project_root_mismatch" in kinds
|
|
assert "dirty_worktree" in kinds
|
|
assert "untracked_files" in kinds
|
|
assert "behind_upstream" in kinds
|
|
assert "ahead_upstream" in kinds
|
|
|
|
|
|
def test_build_repo_state_returns_none_outside_git(monkeypatch):
|
|
monkeypatch.setattr(
|
|
cli_main,
|
|
"_git_context",
|
|
lambda cwd=None: {
|
|
"git_root": None,
|
|
"git_sha": None,
|
|
"git_sha_short": None,
|
|
"branch": None,
|
|
},
|
|
)
|
|
|
|
assert cli_main._build_repo_state({"project_root": "/tmp/expected"}) is None
|
|
|
|
|
|
def test_format_active_branches_multiple_branches_one_line_each():
|
|
branches = [
|
|
{
|
|
"branch_name": "b1",
|
|
"commit_hash": "aaaa" * 8,
|
|
"message": "First",
|
|
"author_agent": "claude-code",
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
},
|
|
{
|
|
"branch_name": "b2",
|
|
"commit_hash": "bbbb" * 8,
|
|
"message": "Second",
|
|
"author_agent": "codex-local",
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
},
|
|
]
|
|
out = _format_active_branches_section(branches)
|
|
# One bullet per branch.
|
|
assert out.count("- `") == 2
|
|
assert "First" in out
|
|
assert "Second" in out
|
|
assert "`claude-code`" in out
|
|
assert "`codex-local`" in out
|
|
|
|
|
|
# ── MCP tool wiring tests ───────────────────────────────────────────────────
|
|
|
|
|
|
def _space_dict():
|
|
return {"id": "space-uuid", "name": "my-project", "description": ""}
|
|
|
|
|
|
def _space_state_dict(with_divergence=False):
|
|
base = {
|
|
"space": _space_dict(),
|
|
"head": _base_head(),
|
|
"commit": _base_commit(),
|
|
"active_branches": [],
|
|
"divergence": None,
|
|
}
|
|
if with_divergence:
|
|
base["active_branches"] = [
|
|
{
|
|
"branch_name": "fork-a",
|
|
"commit_id": "fork-uuid",
|
|
"commit_hash": "abcd" * 8,
|
|
"message": "Fork alt",
|
|
"author_agent": "codex-local",
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
"summary": "Fork summary.",
|
|
}
|
|
]
|
|
base["divergence"] = {
|
|
"pairs": [
|
|
{
|
|
"branch_name": "fork-a",
|
|
"branch_commit_hash": "abcd" * 8,
|
|
"main_only_decisions": ["Decision A"],
|
|
"branch_only_decisions": ["Decision B"],
|
|
}
|
|
]
|
|
}
|
|
return base
|
|
|
|
|
|
def test_mcp_smriti_state_default_calls_get_space_state(mock_client):
|
|
"""Default path: smriti_state → resolve_space → get_space_state (one call)."""
|
|
mock_client.resolve_space.return_value = _space_dict()
|
|
mock_client.get_space_state.return_value = _space_state_dict()
|
|
|
|
out = mcp_server.smriti_state(space="my-project")
|
|
|
|
mock_client.get_space_state.assert_called_once_with("space-uuid", since="")
|
|
mock_client.get_head.assert_not_called()
|
|
mock_client.get_commit.assert_not_called()
|
|
assert "# my-project" in out
|
|
|
|
|
|
def test_mcp_smriti_state_main_only_uses_legacy_path(mock_client):
|
|
"""main_only=True falls back to the old get_head + get_commit dance."""
|
|
mock_client.resolve_space.return_value = _space_dict()
|
|
mock_client.get_head.return_value = _base_head()
|
|
mock_client.get_commit.return_value = _base_commit()
|
|
|
|
out = mcp_server.smriti_state(space="my-project", main_only=True)
|
|
|
|
mock_client.get_head.assert_called_once_with("space-uuid")
|
|
mock_client.get_commit.assert_called_once_with("commit-uuid")
|
|
mock_client.get_space_state.assert_not_called()
|
|
assert "# my-project" in out
|
|
|
|
|
|
def test_mcp_smriti_state_default_renders_divergence_signal(mock_client):
|
|
"""End-to-end: default path + a divergent space_state → divergence
|
|
section visible in rendered output."""
|
|
mock_client.resolve_space.return_value = _space_dict()
|
|
mock_client.get_space_state.return_value = _space_state_dict(with_divergence=True)
|
|
|
|
out = mcp_server.smriti_state(space="my-project")
|
|
|
|
assert "## Active branches" in out
|
|
assert "## Divergence signal" in out
|
|
assert "Decision A" in out
|
|
assert "Decision B" in out
|
|
|
|
|
|
def test_mcp_smriti_state_no_checkpoints_short_circuit(mock_client):
|
|
"""Space exists but has no main commit: default path returns the
|
|
empty-space message without calling format_state_brief."""
|
|
empty_state = _space_state_dict()
|
|
empty_state["head"] = {
|
|
"repo_id": "space-uuid",
|
|
"commit_hash": None,
|
|
"commit_id": None,
|
|
"summary": None,
|
|
"objective": None,
|
|
"latest_session_id": None,
|
|
"latest_session_title": None,
|
|
}
|
|
empty_state["commit"] = None
|
|
mock_client.resolve_space.return_value = _space_dict()
|
|
mock_client.get_space_state.return_value = empty_state
|
|
|
|
out = mcp_server.smriti_state(space="my-project")
|
|
|
|
assert "No checkpoints yet" in out
|
|
|
|
|
|
# ── Compact mode tests ──────────────────────────────────────────────────────
|
|
|
|
|
|
def _commit_with_artifacts():
|
|
"""Commit with realistic artifacts for compact mode testing."""
|
|
base = _base_commit()
|
|
base["id"] = "abc12345-6789-0abc-def0-123456789abc"
|
|
base["artifacts"] = [
|
|
{
|
|
"id": "a1",
|
|
"type": "python",
|
|
"label": "Draft implementation",
|
|
"content": "def hello():\n return 'world'\n" * 50,
|
|
},
|
|
{
|
|
"id": "a2",
|
|
"type": "markdown",
|
|
"label": "Test plan",
|
|
"content": "# Test Plan\n\n- Unit tests for X\n- Integration tests for Y\n" * 30,
|
|
},
|
|
]
|
|
return base
|
|
|
|
|
|
def test_compact_omits_artifact_content():
|
|
"""Compact mode shows artifact labels but not content."""
|
|
commit = _commit_with_artifacts()
|
|
out = format_state_brief(
|
|
_base_space(), _base_head(), commit, compact=True,
|
|
)
|
|
|
|
# Labels present
|
|
assert "Draft implementation" in out
|
|
assert "Test plan" in out
|
|
# Content absent
|
|
assert "def hello():" not in out
|
|
assert "Unit tests for X" not in out
|
|
# Recovery instruction present
|
|
assert "compact — content omitted" in out
|
|
assert "smriti checkpoint show" in out
|
|
assert "abc12345-6789-0abc-def0-123456789abc" in out
|
|
|
|
|
|
def test_compact_is_smaller_than_full():
|
|
"""Compact output must be materially smaller than full output."""
|
|
commit = _commit_with_artifacts()
|
|
full = format_state_brief(
|
|
_base_space(), _base_head(), commit, full_artifacts=True,
|
|
)
|
|
compact = format_state_brief(
|
|
_base_space(), _base_head(), commit, compact=True,
|
|
)
|
|
|
|
# Compact should be at least 50% smaller when artifacts dominate
|
|
assert len(compact) < len(full) * 0.5, (
|
|
f"Compact ({len(compact)} chars) should be less than 50% of "
|
|
f"full ({len(full)} chars)"
|
|
)
|
|
|
|
|
|
def test_compact_preserves_decisions_and_tasks():
|
|
"""Compact mode must not touch non-artifact sections."""
|
|
commit = _commit_with_artifacts()
|
|
out = format_state_brief(
|
|
_base_space(), _base_head(), commit, compact=True,
|
|
)
|
|
|
|
assert "## Decisions" in out
|
|
assert "Decision A" in out
|
|
assert "## Assumptions we are relying on" in out
|
|
assert "Assumption X" in out
|
|
assert "## In progress" in out
|
|
assert "Task 1" in out
|
|
|
|
|
|
def test_compact_with_no_artifacts_is_clean():
|
|
"""Compact mode on a commit with no artifacts should not show the section."""
|
|
commit = _base_commit() # no artifacts
|
|
out = format_state_brief(
|
|
_base_space(), _base_head(), commit, compact=True,
|
|
)
|
|
|
|
assert "Attached artifacts" not in out
|
|
assert "compact" not in out.lower() or "compact" in out.lower() # no section = no mention
|
|
|
|
|
|
def test_mcp_smriti_state_compact_mode(mock_client):
|
|
"""MCP tool with compact=True should pass compact to formatter."""
|
|
mock_client.resolve_space.return_value = _space_dict()
|
|
state = _space_state_dict()
|
|
state["commit"] = _commit_with_artifacts()
|
|
mock_client.get_space_state.return_value = state
|
|
|
|
out = mcp_server.smriti_state(space="my-project", compact=True)
|
|
|
|
# Labels present, content absent
|
|
assert "Draft implementation" in out
|
|
assert "def hello():" not in out
|
|
assert "compact — content omitted" in out
|
|
|
|
|
|
def test_compact_stats_footer_shows_actual_rendered_sizes():
|
|
"""--compact --stats shows full and compact rendered char counts."""
|
|
commit = _commit_with_artifacts()
|
|
out = format_state_brief(
|
|
_base_space(), _base_head(), commit, compact=True, stats=True,
|
|
)
|
|
|
|
assert "compact stats:" in out
|
|
assert "artifact(s) omitted" in out
|
|
assert "chars saved" in out
|
|
assert "smaller artifact section" in out
|
|
# Must show actual rendered sizes, not heuristic estimates
|
|
assert "full:" in out
|
|
assert "compact:" in out
|
|
assert "chars)" in out
|
|
|
|
|
|
def test_stats_without_compact_shows_explicit_message():
|
|
"""--stats without --compact should say so, not silently produce nothing."""
|
|
commit = _commit_with_artifacts()
|
|
out = format_state_brief(
|
|
_base_space(), _base_head(), commit, stats=True,
|
|
)
|
|
|
|
assert "compact stats:" in out
|
|
assert "--compact not used" in out
|
|
|
|
|
|
def test_stats_with_no_artifacts_shows_explicit_message():
|
|
"""Compact+stats on a commit with no artifacts: explicit message."""
|
|
commit = _base_commit()
|
|
out = format_state_brief(
|
|
_base_space(), _base_head(), commit, compact=True, stats=True,
|
|
)
|
|
|
|
assert "compact stats:" in out
|
|
assert "0 artifacts" in out
|
|
|
|
|
|
# ── Structured task rendering tests ────────────────────────────────────────
|
|
|
|
|
|
def test_normalize_task_item_string():
|
|
"""Plain string task normalizes to dict with text key."""
|
|
result = _normalize_task_item("Write tests")
|
|
assert result == {"text": "Write tests"}
|
|
|
|
|
|
def test_normalize_task_item_dict():
|
|
"""Structured task dict passes through unchanged."""
|
|
task = {"text": "Add endpoint", "intent_hint": "implement", "blocked_by": "schema"}
|
|
result = _normalize_task_item(task)
|
|
assert result == task
|
|
|
|
|
|
def test_normalize_task_item_coerces_list_blocked_by():
|
|
"""A list-valued blocked_by is normalized to a comma-separated string."""
|
|
task = {"text": "Wire the limiter", "blocked_by": ["middleware", "load-test"]}
|
|
result = _normalize_task_item(task)
|
|
assert result["blocked_by"] == "middleware, load-test"
|
|
|
|
|
|
def test_task_section_empty():
|
|
"""Empty task list produces empty string."""
|
|
assert _task_section([]) == ""
|
|
|
|
|
|
def test_task_section_legacy_string_tasks():
|
|
"""Old-style string tasks render as plain bullets."""
|
|
out = _task_section(["Task A", "Task B"])
|
|
assert "## In progress" in out
|
|
assert "- Task A" in out
|
|
assert "- Task B" in out
|
|
# No intent or status annotations
|
|
assert "[" not in out
|
|
assert "→" not in out
|
|
|
|
|
|
def test_task_section_structured_with_intent():
|
|
"""Structured task with intent_hint renders inline tag."""
|
|
tasks = [
|
|
{"text": "Add freshness tests", "intent_hint": "test"},
|
|
]
|
|
out = _task_section(tasks)
|
|
assert "- Add freshness tests [test]" in out
|
|
|
|
|
|
def test_task_section_accepts_legacy_intent_type():
|
|
"""Older demo/checkpoint data with intent_type still renders as structured."""
|
|
tasks = [
|
|
{"text": "Update docs", "intent_type": "docs"},
|
|
]
|
|
out = _task_section(tasks)
|
|
assert "- Update docs [docs]" in out
|
|
|
|
|
|
def test_task_section_structured_with_blocked_by():
|
|
"""Structured task with blocked_by renders inline marker."""
|
|
tasks = [
|
|
{"text": "Write test suite", "blocked_by": "freshness-impl"},
|
|
]
|
|
out = _task_section(tasks)
|
|
assert "→ blocked by: freshness-impl" in out
|
|
|
|
|
|
def test_task_section_renders_list_valued_blocked_by():
|
|
"""A list-valued blocked_by renders as a clean comma-separated string,
|
|
not a raw Python list repr — consistent with `smriti current`."""
|
|
tasks = [
|
|
{"text": "Wire the limiter", "blocked_by": ["middleware", "load-test"]},
|
|
]
|
|
out = _task_section(tasks)
|
|
assert "→ blocked by: middleware, load-test" in out
|
|
assert "['" not in out # not the raw Python list repr
|
|
|
|
|
|
def test_task_section_structured_with_done_status():
|
|
"""Task with status=done renders inline marker."""
|
|
tasks = [
|
|
{"text": "Implement endpoint", "status": "done"},
|
|
]
|
|
out = _task_section(tasks)
|
|
assert "(done)" in out
|
|
|
|
|
|
def test_task_section_open_status_not_rendered():
|
|
"""Task with status=open should NOT show (open) — it's the default."""
|
|
tasks = [
|
|
{"text": "Implement endpoint", "status": "open"},
|
|
]
|
|
out = _task_section(tasks)
|
|
assert "(open)" not in out
|
|
assert "Implement endpoint" in out
|
|
|
|
|
|
def test_task_section_full_structured_task():
|
|
"""Task with all annotations renders them in order."""
|
|
tasks = [
|
|
{
|
|
"text": "Write integration tests",
|
|
"intent_hint": "test",
|
|
"status": "open",
|
|
"blocked_by": "freshness-impl",
|
|
},
|
|
]
|
|
out = _task_section(tasks)
|
|
assert "- Write integration tests [test] → blocked by: freshness-impl" in out
|
|
assert "(open)" not in out # open is default, not rendered
|
|
|
|
|
|
def test_task_section_mixed_string_and_structured():
|
|
"""Mix of legacy string and structured tasks renders correctly."""
|
|
tasks = [
|
|
"Legacy task string",
|
|
{"text": "New structured task", "intent_hint": "implement"},
|
|
{"text": "Done task", "status": "done"},
|
|
]
|
|
out = _task_section(tasks)
|
|
assert "- Legacy task string" in out
|
|
assert "- New structured task [implement]" in out
|
|
assert "- Done task (done)" in out
|
|
|
|
|
|
def test_task_section_custom_heading():
|
|
"""Custom heading parameter is respected."""
|
|
tasks = [{"text": "Some task"}]
|
|
out = _task_section(tasks, heading="Tasks")
|
|
assert "## Tasks" in out
|
|
assert "## In progress" not in out
|
|
|
|
|
|
def test_state_brief_with_structured_tasks():
|
|
"""State brief renders structured tasks from commit data."""
|
|
commit = _base_commit()
|
|
commit["tasks"] = [
|
|
{"text": "Add freshness endpoint", "intent_hint": "implement"},
|
|
{"text": "Write freshness tests", "intent_hint": "test", "blocked_by": "freshness-endpoint"},
|
|
"Legacy string task",
|
|
]
|
|
out = format_state_brief(_base_space(), _base_head(), commit)
|
|
|
|
assert "## In progress" in out
|
|
assert "Add freshness endpoint [implement]" in out
|
|
assert "Write freshness tests [test] → blocked by: freshness-endpoint" in out
|
|
assert "- Legacy string task" in out
|
|
|
|
|
|
# ── Task ID rendering tests ────────────────────────────────────────────────
|
|
|
|
|
|
def test_task_section_with_id():
|
|
"""Task with an id renders (id: ...) annotation."""
|
|
tasks = [
|
|
{"id": "impl-1", "text": "Implement endpoint", "intent_hint": "implement"},
|
|
]
|
|
out = _task_section(tasks)
|
|
assert "- Implement endpoint [implement] (id: impl-1)" in out
|
|
|
|
|
|
def test_task_section_without_id():
|
|
"""Task without id renders normally — no (id: ...) annotation."""
|
|
tasks = [
|
|
{"text": "Implement endpoint", "intent_hint": "implement"},
|
|
]
|
|
out = _task_section(tasks)
|
|
assert "(id:" not in out
|
|
assert "Implement endpoint [implement]" in out
|
|
|
|
|
|
def test_task_section_mixed_with_and_without_ids():
|
|
"""Mix of tasks with and without IDs renders correctly."""
|
|
tasks = [
|
|
{"id": "docs-arch", "text": "Update ARCHITECTURE.md", "intent_hint": "docs"},
|
|
{"text": "Legacy task without ID"},
|
|
{"id": "test-e2e", "text": "Write e2e test", "intent_hint": "test", "blocked_by": "impl-1"},
|
|
]
|
|
out = _task_section(tasks)
|
|
assert "(id: docs-arch)" in out
|
|
assert "(id:" not in out.split("Legacy task")[1].split("\n")[0] # legacy line has no id
|
|
assert "(id: test-e2e)" in out
|
|
assert "→ blocked by: impl-1" in out
|
|
|
|
|
|
def test_claims_section_with_task_id():
|
|
"""Active claim with task_id renders (task: ...) suffix."""
|
|
from smriti_cli.formatters import _format_active_claims_section
|
|
claims = [
|
|
{
|
|
"agent": "claude-code",
|
|
"branch_name": "main",
|
|
"scope": "Update ARCHITECTURE.md",
|
|
"task_id": "docs-arch",
|
|
"intent_type": "docs",
|
|
"base_commit_hash": "abc1234",
|
|
"claimed_at": datetime.now(timezone.utc).isoformat(),
|
|
},
|
|
]
|
|
out = _format_active_claims_section(claims)
|
|
assert "(task: docs-arch)" in out
|
|
assert "[docs]" in out
|
|
|
|
|
|
def test_claims_section_without_task_id():
|
|
"""Active claim without task_id renders normally — no (task: ...) suffix."""
|
|
from smriti_cli.formatters import _format_active_claims_section
|
|
claims = [
|
|
{
|
|
"agent": "codex-local",
|
|
"branch_name": "main",
|
|
"scope": "Some work",
|
|
"intent_type": "implement",
|
|
"base_commit_hash": "def5678",
|
|
"claimed_at": datetime.now(timezone.utc).isoformat(),
|
|
},
|
|
]
|
|
out = _format_active_claims_section(claims)
|
|
assert "(task:" not in out
|
|
assert "[implement]" in out
|
|
|
|
|
|
def test_claims_section_with_worktree_info():
|
|
"""Bound worktree info renders as continuation lines under the claim."""
|
|
from smriti_cli.formatters import _format_active_claims_section
|
|
claims = [
|
|
{
|
|
"agent": "codex-local",
|
|
"branch_name": "worktree-v2-binding-and-enrichment",
|
|
"scope": "Implement V2",
|
|
"task_id": "v2-plan",
|
|
"worktree_id": "worktree-uuid",
|
|
"worktree": {
|
|
"id": "worktree-uuid",
|
|
"path": "/Users/example/.smriti/worktrees/smriti-dev/codex-local-abc12345",
|
|
"branch": "smriti/codex-local/abc12345",
|
|
"dirty_files": 3,
|
|
"ahead": 1,
|
|
"behind": 0,
|
|
"last_commit_sha": "def5678",
|
|
"last_commit_relative": "5 minutes ago",
|
|
},
|
|
"intent_type": "implement",
|
|
"base_commit_hash": "abc1234",
|
|
"claimed_at": datetime.now(timezone.utc).isoformat(),
|
|
},
|
|
]
|
|
|
|
out = _format_active_claims_section(claims)
|
|
|
|
assert "worktree:" in out
|
|
assert "smriti/codex-local/abc12345" in out
|
|
assert "3 dirty" in out
|
|
assert "ahead 1" in out
|
|
assert "behind 0" in out
|
|
assert "last commit `def5678` 5 minutes ago" in out
|
|
|
|
|
|
def test_render_dirty_paths_inline():
|
|
from smriti_cli.formatters import _format_active_claims_section
|
|
|
|
claims = [_worktree_claim(dirty_files=3, dirty_paths=[
|
|
"cli/main.py",
|
|
"backend/app/api/routes/chat.py",
|
|
"backend/app/services/worktree_probe.py",
|
|
])]
|
|
|
|
out = _format_active_claims_section(claims)
|
|
|
|
assert (
|
|
"3 dirty (cli/main.py, backend/app/api/routes/chat.py, "
|
|
"backend/app/services/worktree_probe.py)"
|
|
) in out
|
|
|
|
|
|
def test_render_dirty_paths_with_overflow():
|
|
from smriti_cli.formatters import _format_active_claims_section
|
|
|
|
claims = [_worktree_claim(dirty_files=5, dirty_paths=[
|
|
"cli/main.py",
|
|
"backend/app/api/routes/chat.py",
|
|
"backend/app/services/worktree_probe.py",
|
|
])]
|
|
|
|
out = _format_active_claims_section(claims)
|
|
|
|
assert (
|
|
"5 dirty (cli/main.py, backend/app/api/routes/chat.py, "
|
|
"backend/app/services/worktree_probe.py, +2 more)"
|
|
) in out
|
|
|
|
|
|
def test_render_dirty_paths_zero():
|
|
from smriti_cli.formatters import _format_active_claims_section
|
|
|
|
claims = [_worktree_claim(dirty_files=0, dirty_paths=[])]
|
|
|
|
out = _format_active_claims_section(claims)
|
|
|
|
assert "0 dirty · ahead" in out
|
|
assert "0 dirty (" not in out
|
|
|
|
|
|
def test_claims_section_with_worktree_probe_failure():
|
|
"""A bound claim with failed probing still renders a useful hint."""
|
|
from smriti_cli.formatters import _format_active_claims_section
|
|
claims = [
|
|
{
|
|
"agent": "codex-local",
|
|
"branch_name": "main",
|
|
"scope": "Implement V2",
|
|
"worktree_id": "worktree-uuid",
|
|
"worktree": None,
|
|
"intent_type": "implement",
|
|
"base_commit_hash": "abc1234",
|
|
"claimed_at": datetime.now(timezone.utc).isoformat(),
|
|
},
|
|
]
|
|
|
|
out = _format_active_claims_section(claims)
|
|
|
|
assert "probe failed or worktree closed" in out
|
|
|
|
|
|
def _worktree_claim(dirty_files: int, dirty_paths: list[str]) -> dict:
|
|
return {
|
|
"agent": "codex-local",
|
|
"branch_name": "v3-dirty-paths-and-skill-pack",
|
|
"scope": "Implement V3",
|
|
"worktree_id": "worktree-uuid",
|
|
"worktree": {
|
|
"id": "worktree-uuid",
|
|
"path": "/Users/example/.smriti/worktrees/smriti-dev/codex-local-abc12345",
|
|
"branch": "v3-dirty-paths-and-skill-pack",
|
|
"dirty_files": dirty_files,
|
|
"dirty_paths": dirty_paths,
|
|
"ahead": 1,
|
|
"behind": 0,
|
|
"last_commit_sha": "def5678",
|
|
"last_commit_relative": "5 minutes ago",
|
|
},
|
|
"intent_type": "implement",
|
|
"base_commit_hash": "abc1234",
|
|
"claimed_at": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
|
|
|
|
# ── repo-state drift: checkpoint-relative comparison ──────────────────────────
|
|
|
|
|
|
def _commit_with_repo_state(head, head_short="", branch="main"):
|
|
"""A head-checkpoint dict carrying recorded git provenance, shaped like
|
|
what the V4 state endpoint returns to `smriti state`."""
|
|
return {
|
|
"context_blob": {
|
|
"repo_state": {
|
|
"head": head,
|
|
"head_short": head_short or head[:7],
|
|
"branch": branch,
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
def test_compare_to_checkpoint_none_when_unrecorded():
|
|
# Checkpoints created before this feature, or by the MCP server, carry no
|
|
# recorded git state — the comparison is skipped, never faked.
|
|
assert cli_main._compare_to_checkpoint(None, "/repo", "abc123", "main") is None
|
|
assert cli_main._compare_to_checkpoint({}, "/repo", "abc123", "main") is None
|
|
assert cli_main._compare_to_checkpoint(
|
|
{"context_blob": {}}, "/repo", "abc123", "main"
|
|
) is None
|
|
|
|
|
|
def test_compare_to_checkpoint_in_sync():
|
|
commit = _commit_with_repo_state("abc123def", branch="main")
|
|
result = cli_main._compare_to_checkpoint(commit, "/repo", "abc123def", "main")
|
|
assert result["relation"] == "in_sync"
|
|
assert result["branch_changed"] is False
|
|
assert result["signals"] == []
|
|
|
|
|
|
def test_compare_to_checkpoint_ahead_flags_stale(monkeypatch):
|
|
commit = _commit_with_repo_state("oldsha", branch="main")
|
|
# "oldsha..HEAD" -> 3 commits ahead; "HEAD..oldsha" -> 0 behind.
|
|
monkeypatch.setattr(
|
|
cli_main, "_git_rev_count",
|
|
lambda cwd, rng: 3 if rng.endswith("..HEAD") else 0,
|
|
)
|
|
result = cli_main._compare_to_checkpoint(commit, "/repo", "newsha", "main")
|
|
assert result["relation"] == "ahead"
|
|
assert result["ahead"] == 3
|
|
assert {s["kind"] for s in result["signals"]} == {"ahead_of_checkpoint"}
|
|
assert any("stale" in s["message"] for s in result["signals"])
|
|
|
|
|
|
def test_compare_to_checkpoint_diverged(monkeypatch):
|
|
commit = _commit_with_repo_state("oldsha", branch="main")
|
|
monkeypatch.setattr(cli_main, "_git_rev_count", lambda cwd, rng: 2)
|
|
result = cli_main._compare_to_checkpoint(commit, "/repo", "newsha", "main")
|
|
assert result["relation"] == "diverged"
|
|
assert {s["kind"] for s in result["signals"]} == {"diverged_from_checkpoint"}
|
|
|
|
|
|
def test_compare_to_checkpoint_unknown_when_commit_missing(monkeypatch):
|
|
commit = _commit_with_repo_state("missingsha", branch="main")
|
|
monkeypatch.setattr(cli_main, "_git_rev_count", lambda cwd, rng: None)
|
|
result = cli_main._compare_to_checkpoint(commit, "/repo", "currentsha", "main")
|
|
assert result["relation"] == "unknown"
|
|
assert {s["kind"] for s in result["signals"]} == {"checkpoint_commit_missing"}
|
|
|
|
|
|
def test_compare_to_checkpoint_flags_branch_change():
|
|
commit = _commit_with_repo_state("abc123", branch="main")
|
|
# Same HEAD, but the repo is now on a different branch than the checkpoint.
|
|
result = cli_main._compare_to_checkpoint(commit, "/repo", "abc123", "feature-x")
|
|
assert result["relation"] == "in_sync"
|
|
assert result["branch_changed"] is True
|
|
signal = next(
|
|
s for s in result["signals"] if s["kind"] == "checkpoint_branch_changed"
|
|
)
|
|
assert "main" in signal["message"]
|
|
|
|
|
|
def test_build_repo_state_includes_checkpoint_comparison(monkeypatch):
|
|
monkeypatch.setattr(
|
|
cli_main, "_git_context",
|
|
lambda cwd=None: {
|
|
"git_root": "/repo", "git_sha": "newsha",
|
|
"git_sha_short": "newsha1", "branch": "main",
|
|
},
|
|
)
|
|
monkeypatch.setattr(cli_main, "_git_ahead_behind", lambda cwd: (0, 0))
|
|
monkeypatch.setattr(cli_main, "_git_dirty_count", lambda cwd: 0)
|
|
monkeypatch.setattr(cli_main, "_git_porcelain_count", lambda cwd, prefix: 0)
|
|
monkeypatch.setattr(
|
|
cli_main, "_git_rev_count",
|
|
lambda cwd, rng: 4 if rng.endswith("..HEAD") else 0,
|
|
)
|
|
commit = _commit_with_repo_state("oldsha", branch="main")
|
|
repo_state = cli_main._build_repo_state({"project_root": "/repo"}, commit)
|
|
|
|
assert repo_state is not None
|
|
assert repo_state["checkpoint"]["relation"] == "ahead"
|
|
assert repo_state["checkpoint"]["ahead"] == 4
|
|
# The checkpoint drift signal is merged into the section's signal list.
|
|
assert any(s["kind"] == "ahead_of_checkpoint" for s in repo_state["signals"])
|
|
|
|
|
|
def test_build_repo_state_checkpoint_none_without_commit(monkeypatch):
|
|
monkeypatch.setattr(
|
|
cli_main, "_git_context",
|
|
lambda cwd=None: {
|
|
"git_root": "/repo", "git_sha": "sha",
|
|
"git_sha_short": "sha1", "branch": "main",
|
|
},
|
|
)
|
|
monkeypatch.setattr(cli_main, "_git_ahead_behind", lambda cwd: (0, 0))
|
|
monkeypatch.setattr(cli_main, "_git_dirty_count", lambda cwd: 0)
|
|
monkeypatch.setattr(cli_main, "_git_porcelain_count", lambda cwd, prefix: 0)
|
|
repo_state = cli_main._build_repo_state({"project_root": "/repo"})
|
|
assert repo_state["checkpoint"] is None
|
|
|
|
|
|
def test_format_repo_state_section_renders_checkpoint_line():
|
|
out = _format_repo_state_section({
|
|
"git_root": "/repo", "branch": "main", "detached": False,
|
|
"head_short": "newsha1", "upstream_known": False,
|
|
"project_root_matches": None,
|
|
"checkpoint": {
|
|
"head_short": "oldsha1", "branch": "main", "relation": "ahead",
|
|
"ahead": 3, "behind": 0, "branch_changed": False, "signals": [],
|
|
},
|
|
"signals": [],
|
|
})
|
|
assert "vs last checkpoint `oldsha1`" in out
|
|
assert "repo is 3 commit(s) ahead" in out
|
|
|
|
|
|
def test_format_repo_state_section_checkpoint_in_sync_and_branch_change():
|
|
out = _format_repo_state_section({
|
|
"git_root": "/repo", "branch": "feature", "detached": False,
|
|
"head_short": "sha1", "upstream_known": False,
|
|
"project_root_matches": None,
|
|
"checkpoint": {
|
|
"head_short": "sha1", "branch": "main", "relation": "in_sync",
|
|
"ahead": 0, "behind": 0, "branch_changed": True, "signals": [],
|
|
},
|
|
"signals": [],
|
|
})
|
|
assert "repo unchanged since it" in out
|
|
assert "checkpoint on branch `main`" in out
|
|
|
|
|
|
def test_checkpoint_repo_state_captures_head_and_branch(monkeypatch):
|
|
monkeypatch.setattr(
|
|
cli_main, "_git_context",
|
|
lambda cwd=None: {
|
|
"git_root": "/repo", "git_sha": "fullsha123",
|
|
"git_sha_short": "fullsha", "branch": "main",
|
|
},
|
|
)
|
|
assert cli_main._checkpoint_repo_state() == {
|
|
"head": "fullsha123", "head_short": "fullsha", "branch": "main",
|
|
}
|
|
|
|
|
|
def test_checkpoint_repo_state_empty_outside_git(monkeypatch):
|
|
monkeypatch.setattr(
|
|
cli_main, "_git_context",
|
|
lambda cwd=None: {
|
|
"git_root": None, "git_sha": None,
|
|
"git_sha_short": None, "branch": None,
|
|
},
|
|
)
|
|
assert cli_main._checkpoint_repo_state() == {}
|
|
|
|
|
|
def test_checkpoint_repo_state_detached_head_records_no_branch(monkeypatch):
|
|
monkeypatch.setattr(
|
|
cli_main, "_git_context",
|
|
lambda cwd=None: {
|
|
"git_root": "/repo", "git_sha": "sha",
|
|
"git_sha_short": "sha1", "branch": "HEAD",
|
|
},
|
|
)
|
|
assert cli_main._checkpoint_repo_state()["branch"] is None
|