diff --git a/amplifier_app_cli/commands/session.py b/amplifier_app_cli/commands/session.py index 7c82579..5df9a9a 100644 --- a/amplifier_app_cli/commands/session.py +++ b/amplifier_app_cli/commands/session.py @@ -147,6 +147,17 @@ def _prepare_resume_context( project_slug=project_slug, ) + # Resume-time provider-mismatch guard (issue #208). Advisory: warns on a + # cross-provider resume and, on a TTY, lets the user abort. Non-interactive + # resumes only log and proceed, so automation is never blocked. + from ..provider_guard import check_resume_provider + + if not check_resume_provider( + session_id, config_data, console, base_dir=store.base_dir + ): + console.print("[yellow]Resume cancelled.[/yellow]") + raise SystemExit(0) + search_paths = get_module_search_paths() # Determine active_bundle for display @@ -1150,6 +1161,25 @@ def sessions_cleanup(days: int, force: bool): f"[green]✓[/green] Removed {removed} sessions older than {cutoff:%Y-%m-%d}" ) + @session.command(name="diagnose") + @click.argument("session_id") + def sessions_diagnose(session_id: str): + """Diagnose a session's transcript health (read-only).""" + _diagnose_session(SessionStore(), session_id) + + @session.command(name="repair") + @click.argument("session_id") + def sessions_repair(session_id: str): + """Repair structural damage in a session's transcript. + + Fixes tool-call damage (orphaned tool calls, ordering violations, + incomplete turns) using the COMPLETE strategy. Writes a timestamped + backup before any change and is idempotent (a healthy session is a + no-op). Does NOT repair cross-provider thinking-block damage -- that is + handled at the provider edge (#207). + """ + _repair_session(SessionStore(), session_id) + # Register interactive resume on root CLI (not session subgroup) @cli.command(name="resume") @click.argument("session_id", required=False, default=None) @@ -1286,6 +1316,95 @@ def _get_session_display_info(store: SessionStore, session_id: str) -> dict: return info +_FOUNDATION_REPAIR_HINT = ( + "Session repair tooling requires a newer amplifier-foundation than is " + "installed. Update with: uv lock --upgrade-package amplifier-foundation" +) + + +def _diagnose_session(store: SessionStore, session_id: str) -> None: + """Print transcript health for a session (read-only).""" + try: + full_id = store.find_session(session_id, top_level_only=False) + except FileNotFoundError: + console.print(f"[red]Error:[/red] No session found matching '{session_id}'") + sys.exit(1) + except ValueError as e: + console.print(f"[red]Error:[/red] {escape_markup(e)}") + sys.exit(1) + + try: + from amplifier_foundation.session import session_info + except ImportError: + console.print(f"[red]Error:[/red] {_FOUNDATION_REPAIR_HINT}") + sys.exit(1) + + info = session_info(store.base_dir / full_id) + if info.get("status") == "healthy": + console.print( + f"[green]✓[/green] Session {full_id[:8]} is healthy — no repair needed." + ) + return + modes = ", ".join(info.get("failure_modes", [])) or "unknown" + console.print(f"[yellow]⚠[/yellow] Session {full_id[:8]} is broken: {modes}") + console.print(f" Repair with: [cyan]amplifier session repair {full_id[:8]}[/cyan]") + + +def _repair_session(store: SessionStore, session_id: str) -> None: + """Repair tool-call structural damage in a session transcript (COMPLETE strategy).""" + try: + full_id = store.find_session(session_id, top_level_only=False) + except FileNotFoundError: + console.print(f"[red]Error:[/red] No session found matching '{session_id}'") + sys.exit(1) + except ValueError as e: + console.print(f"[red]Error:[/red] {escape_markup(e)}") + sys.exit(1) + + try: + from amplifier_foundation.session import ( + TRANSCRIPT_FILENAME, + backup, + diagnose_transcript, + load_transcript_with_lines, + repair_transcript, + write_transcript, + ) + except ImportError: + console.print(f"[red]Error:[/red] {_FOUNDATION_REPAIR_HINT}") + sys.exit(1) + + session_dir = store.base_dir / full_id + entries = load_transcript_with_lines(session_dir) + diagnosis = diagnose_transcript(entries) + if diagnosis["status"] != "broken": + console.print( + f"[green]✓[/green] Session {full_id[:8]} is already healthy — nothing to repair." + ) + return + + backup_path = backup(session_dir / TRANSCRIPT_FILENAME, "repair") + repaired = repair_transcript(entries, diagnosis) + write_transcript(session_dir, repaired) + + # Self-verify by re-diagnosing the written file. + verify = diagnose_transcript(load_transcript_with_lines(session_dir)) + + console.print(f"[green]✓[/green] Repaired session {full_id[:8]}") + console.print( + f" Fixed: {', '.join(diagnosis.get('failure_modes', [])) or 'structural issues'}" + ) + if backup_path: + console.print(f" Backup: {backup_path}") + if verify["status"] == "healthy": + console.print(" Verified: transcript is now healthy") + else: + console.print( + f" [yellow]⚠ Still broken after repair: " + f"{', '.join(verify.get('failure_modes', [])) or 'unknown'}[/yellow]" + ) + + def _interactive_resume_impl( ctx: click.Context, limit: int, diff --git a/amplifier_app_cli/provider_guard.py b/amplifier_app_cli/provider_guard.py new file mode 100644 index 0000000..f117cb1 --- /dev/null +++ b/amplifier_app_cli/provider_guard.py @@ -0,0 +1,181 @@ +"""Resume-time provider-mismatch guard (issue #208, Option A). + +Warns when a session is resumed under a different LLM provider than the one +that last wrote to it. Cross-provider resumes can brick a session because saved +history may carry provider-specific content blocks (reasoning / thinking blocks) +that the new provider's API rejects (see #206 / #207). + +The last-writing provider is derived from the session's persisted events.jsonl: +the last ``llm:response`` event records ``data.provider`` and ``data.model``. +This mirrors the memory-safe, line-by-line read in +``cost_history.sum_prior_cost_usd`` (events lines can be very large). + +Provider mismatch is the dangerous axis. Model-only changes within one provider +are safe and stay silent. +""" + +from __future__ import annotations + +import json +import logging +import sys +from pathlib import Path + +logger = logging.getLogger(__name__) + +_LLM_RESPONSE_EVENT = "llm:response" + + +def last_writing_provider(events_path: Path) -> tuple[str | None, str | None]: + """Return (provider, model) from the LAST ``llm:response`` event. + + Returns (None, None) when the file is missing/unreadable or has no + ``llm:response`` events. Reads one line at a time with a cheap substring + pre-filter so huge lines are never all held in memory. Never raises. + """ + if not events_path.is_file(): + return (None, None) + provider: str | None = None + model: str | None = None + try: + with events_path.open("r", encoding="utf-8") as handle: + for line in handle: + if _LLM_RESPONSE_EVENT not in line: + continue + try: + event = json.loads(line) + except (json.JSONDecodeError, ValueError): + continue + if ( + not isinstance(event, dict) + or event.get("event") != _LLM_RESPONSE_EVENT + ): + continue + data = event.get("data") + if not isinstance(data, dict): + continue + p = data.get("provider") + if isinstance(p, str) and p: + provider = p + m = data.get("model") + model = m if isinstance(m, str) and m else None + except OSError: + logger.debug("Provider guard: could not read %s", events_path, exc_info=True) + return (None, None) + return (provider, model) + + +def _first_provider(config_data: dict) -> dict | None: + providers = config_data.get("providers") + if isinstance(providers, list) and providers and isinstance(providers[0], dict): + return providers[0] + return None + + +def active_provider_aliases(config_data: dict) -> set[str]: + """Lower-cased identity aliases the resuming session will write under. + + Union of {module, module without the ``provider-`` prefix, id, instance_id} + so the events ``data.provider`` short-name matches whichever form the + provider self-reports. Empty set when no providers are resolved. + """ + first = _first_provider(config_data) + if first is None: + return set() + aliases: set[str] = set() + module = first.get("module") + if isinstance(module, str) and module: + aliases.add(module.lower()) + aliases.add(module.removeprefix("provider-").lower()) + for key in ("instance_id", "id"): + val = first.get(key) + if isinstance(val, str) and val: + aliases.add(val.lower()) + return aliases + + +def active_provider_display(config_data: dict) -> str: + first = _first_provider(config_data) + module = (first or {}).get("module") + if isinstance(module, str) and module: + return module.removeprefix("provider-") + return "the active provider" + + +def active_model(config_data: dict) -> str | None: + first = _first_provider(config_data) + cfg = (first or {}).get("config") + if isinstance(cfg, dict): + m = cfg.get("model") or cfg.get("default_model") + return m if isinstance(m, str) and m else None + return None + + +def _short(model: str | None) -> str: + """Match the display convention in _display_session_history (strip vendor prefix).""" + if not model: + return "?" + return model.split("/")[-1] + + +def check_resume_provider( + session_id: str, + config_data: dict, + console, + *, + base_dir: Path, + is_tty: bool | None = None, +) -> bool: + """Warn on provider mismatch at resume. Return True to proceed, False to abort. + + Decision table: + - no last-writing provider (no llm:response events) -> True (silent) + - active provider unknown (no resolved providers) -> True (silent) + - last provider matches an active alias -> True (silent; model-only + changes are safe) + - mismatch + non-interactive -> warn to log, True (never + block automation) + - mismatch + interactive TTY -> warn + confirm; return answer + """ + last_provider, last_model = last_writing_provider( + base_dir / session_id / "events.jsonl" + ) + if not last_provider: + return True + aliases = active_provider_aliases(config_data) + if not aliases or last_provider.lower() in aliases: + return True + + active_name = active_provider_display(config_data) + active_mdl = _short(active_model(config_data)) + last_mdl = _short(last_model) + if is_tty is None: + is_tty = sys.stdin.isatty() + + console.print( + "[yellow]⚠ Provider mismatch on resume[/yellow]\n" + f" Last written by: [bold]{last_provider}[/bold] ({last_mdl})\n" + f" Resuming with: [bold]{active_name}[/bold] ({active_mdl})\n" + " [dim]Switching providers mid-session can fail if the saved history " + "contains provider-specific content blocks (e.g. reasoning/thinking blocks).[/dim]" + ) + if not is_tty: + logger.warning( + "Provider mismatch on resume (session %s): last=%s active=%s; " + "proceeding (non-interactive).", + session_id, + last_provider, + active_name, + ) + return True + answer = console.input(" Proceed anyway? [y/N]: ") + return answer.strip().lower() in ("y", "yes") + + +__all__ = [ + "last_writing_provider", + "active_provider_aliases", + "active_provider_display", + "active_model", + "check_resume_provider", +] diff --git a/tests/test_provider_guard.py b/tests/test_provider_guard.py new file mode 100644 index 0000000..00c903b --- /dev/null +++ b/tests/test_provider_guard.py @@ -0,0 +1,286 @@ +"""Tests for the resume-time provider-mismatch guard (issue #208, Option A). + +Covers amplifier_app_cli.provider_guard: +- last_writing_provider(): parse events.jsonl, return the LAST llm:response provider/model +- active_provider_aliases()/active_provider_display()/active_model(): config_data readers +- check_resume_provider(): the decision table (silent / warn+proceed / warn+confirm) + +Tests use a fake console (records .print() calls, scripts .input() answers) so no +real terminal is required, and pass is_tty= explicitly per the spec's test plan. +""" + +from __future__ import annotations + +import json +import logging + +from amplifier_app_cli.provider_guard import active_model +from amplifier_app_cli.provider_guard import active_provider_aliases +from amplifier_app_cli.provider_guard import active_provider_display +from amplifier_app_cli.provider_guard import check_resume_provider +from amplifier_app_cli.provider_guard import last_writing_provider + + +class FakeConsole: + """Minimal console double: records prints, scripts input() answers.""" + + def __init__(self, answer: str = "n"): + self.printed: list[str] = [] + self._answer = answer + self.input_calls = 0 + + def print(self, *args, **kwargs): + self.printed.append(" ".join(str(a) for a in args)) + + def input(self, prompt: str = "") -> str: + self.input_calls += 1 + return self._answer + + +def _write_events(path, events): + path.write_text("\n".join(json.dumps(e) for e in events) + "\n", encoding="utf-8") + + +def _llm_response(provider, model=None): + data = {"provider": provider} + if model is not None: + data["model"] = model + return {"event": "llm:response", "data": data} + + +def _config(module="provider-anthropic", model=None, instance_id=None, id_=None): + provider_entry: dict = {"module": module} + if model is not None: + provider_entry["config"] = {"model": model} + if instance_id is not None: + provider_entry["instance_id"] = instance_id + if id_ is not None: + provider_entry["id"] = id_ + return {"providers": [provider_entry]} + + +# --------------------------------------------------------------------------- +# last_writing_provider +# --------------------------------------------------------------------------- + + +def test_last_writing_provider_returns_last_match(tmp_path): + events = tmp_path / "events.jsonl" + _write_events( + events, + [ + {"event": "session:start", "data": {}}, + _llm_response("openai", "gpt-5.5"), + {"event": "tool:pre", "data": {}}, + _llm_response("anthropic", "claude-fable-5"), + ], + ) + assert last_writing_provider(events) == ("anthropic", "claude-fable-5") + + +def test_last_writing_provider_missing_file(tmp_path): + assert last_writing_provider(tmp_path / "nope.jsonl") == (None, None) + + +def test_last_writing_provider_no_llm_response(tmp_path): + events = tmp_path / "events.jsonl" + _write_events( + events, + [ + {"event": "prompt:submit", "data": {}}, + {"event": "tool:pre", "data": {"tool_name": "bash"}}, + ], + ) + assert last_writing_provider(events) == (None, None) + + +def test_huge_line_safety(tmp_path): + """A huge non-matching line must not be loaded/parsed; the real result must still surface.""" + events = tmp_path / "events.jsonl" + huge_request = json.dumps( + {"event": "llm:request", "data": {"blob": "x" * (2 * 1024 * 1024)}} + ) + lines = [huge_request, json.dumps(_llm_response("anthropic", "claude-fable-5"))] + events.write_text("\n".join(lines) + "\n", encoding="utf-8") + + import time + + start = time.monotonic() + result = last_writing_provider(events) + elapsed = time.monotonic() - start + + assert result == ("anthropic", "claude-fable-5") + assert ( + elapsed < 2.0 + ) # generous ceiling; structural guarantee is line-by-line reading + + +# --------------------------------------------------------------------------- +# config_data readers +# --------------------------------------------------------------------------- + + +def test_active_provider_aliases_includes_module_and_stripped(): + aliases = active_provider_aliases(_config(module="provider-anthropic")) + assert aliases == {"provider-anthropic", "anthropic"} + + +def test_active_provider_aliases_empty_when_no_providers(): + assert active_provider_aliases({}) == set() + assert active_provider_aliases({"providers": []}) == set() + + +def test_active_provider_display_strips_prefix(): + assert active_provider_display(_config(module="provider-anthropic")) == "anthropic" + + +def test_active_model_reads_config_model(): + assert active_model(_config(model="claude-sonnet-5")) == "claude-sonnet-5" + + +# --------------------------------------------------------------------------- +# check_resume_provider decision table +# --------------------------------------------------------------------------- + + +def test_mismatch_non_interactive_proceeds(tmp_path, caplog): + events_dir = tmp_path / "sess1" + events_dir.mkdir() + _write_events(events_dir / "events.jsonl", [_llm_response("openai", "gpt-5.5")]) + console = FakeConsole() + + with caplog.at_level(logging.WARNING): + result = check_resume_provider( + "sess1", + _config(module="provider-anthropic"), + console, + base_dir=tmp_path, + is_tty=False, + ) + + assert result is True + assert any("Provider mismatch" in msg for msg in console.printed) + assert any("Provider mismatch" in record.message for record in caplog.records) + + +def test_mismatch_interactive_abort(tmp_path): + events_dir = tmp_path / "sess1" + events_dir.mkdir() + _write_events(events_dir / "events.jsonl", [_llm_response("openai", "gpt-5.5")]) + console = FakeConsole(answer="n") + + result = check_resume_provider( + "sess1", + _config(module="provider-anthropic"), + console, + base_dir=tmp_path, + is_tty=True, + ) + + assert result is False + assert console.input_calls == 1 + + +def test_mismatch_interactive_confirm(tmp_path): + events_dir = tmp_path / "sess1" + events_dir.mkdir() + _write_events(events_dir / "events.jsonl", [_llm_response("openai", "gpt-5.5")]) + console = FakeConsole(answer="y") + + result = check_resume_provider( + "sess1", + _config(module="provider-anthropic"), + console, + base_dir=tmp_path, + is_tty=True, + ) + + assert result is True + + +def test_same_provider_silent(tmp_path): + events_dir = tmp_path / "sess1" + events_dir.mkdir() + _write_events( + events_dir / "events.jsonl", + [_llm_response("anthropic", "claude-fable-5")], + ) + console = FakeConsole() + + result = check_resume_provider( + "sess1", + _config(module="provider-anthropic", model="claude-sonnet-5"), + console, + base_dir=tmp_path, + is_tty=True, + ) + + assert result is True + assert console.printed == [] + assert console.input_calls == 0 + + +def test_alias_match_named_instance(tmp_path): + events_dir = tmp_path / "sess1" + events_dir.mkdir() + _write_events(events_dir / "events.jsonl", [_llm_response("anthropic")]) + console = FakeConsole() + + result = check_resume_provider( + "sess1", + _config(module="provider-anthropic", instance_id="anthropic-sonnet"), + console, + base_dir=tmp_path, + is_tty=True, + ) + + assert result is True + assert console.printed == [] + + +def test_no_active_providers_silent(tmp_path): + events_dir = tmp_path / "sess1" + events_dir.mkdir() + _write_events(events_dir / "events.jsonl", [_llm_response("openai")]) + console = FakeConsole() + + result = check_resume_provider("sess1", {}, console, base_dir=tmp_path, is_tty=True) + + assert result is True + assert console.printed == [] + + +def test_model_only_change_silent(tmp_path): + """Documents the §2.1.3 policy: same-provider model swaps never warn.""" + events_dir = tmp_path / "sess1" + events_dir.mkdir() + _write_events( + events_dir / "events.jsonl", + [_llm_response("anthropic", "claude-fable-5")], + ) + console = FakeConsole() + + result = check_resume_provider( + "sess1", + _config(module="provider-anthropic", model="claude-sonnet-5"), + console, + base_dir=tmp_path, + is_tty=True, + ) + + assert result is True + assert console.printed == [] + + +def test_no_events_file_silent(tmp_path): + """No events.jsonl at all (brand-new/never-written session) -> silent proceed.""" + console = FakeConsole() + result = check_resume_provider( + "sess-missing", + _config(module="provider-anthropic"), + console, + base_dir=tmp_path, + is_tty=True, + ) + assert result is True + assert console.printed == [] diff --git a/tests/test_resume_provider_guard_wiring.py b/tests/test_resume_provider_guard_wiring.py new file mode 100644 index 0000000..97aadf4 --- /dev/null +++ b/tests/test_resume_provider_guard_wiring.py @@ -0,0 +1,77 @@ +"""Wiring test for the resume-time provider-mismatch guard (issue #208). + +Asserts that ``_prepare_resume_context`` (the single choke point every resume +path funnels through) actually calls the guard and honors an abort by raising +``SystemExit(0)`` -- and that a normal proceed returns the usual 8-tuple with +no exit. ``resolve_config`` is monkeypatched to keep the test light (avoids +exercising the full bundle/provider resolution machinery). +""" + +from __future__ import annotations + +import json + +import pytest + +from amplifier_app_cli.commands import session as session_module +from amplifier_app_cli.session_store import SessionStore + + +def _seed_session(base_dir, session_id: str): + session_dir = base_dir / session_id + session_dir.mkdir(parents=True) + (session_dir / "metadata.json").write_text( + json.dumps({"session_id": session_id, "bundle": "foundation"}), + encoding="utf-8", + ) + (session_dir / "transcript.jsonl").write_text("", encoding="utf-8") + + +@pytest.fixture +def resume_env(tmp_path, monkeypatch): + session_id = "wiring-test-session" + _seed_session(tmp_path, session_id) + + monkeypatch.setattr( + session_module, "SessionStore", lambda: SessionStore(base_dir=tmp_path) + ) + monkeypatch.setattr( + session_module, + "resolve_config", + lambda **kwargs: ({"providers": [{"module": "provider-anthropic"}]}, None), + ) + # Avoid touching real first-run/provider auto-install machinery. + monkeypatch.setattr("amplifier_app_cli.commands.init.check_first_run", lambda: None) + + return session_id + + +def test_guard_abort_raises_system_exit(resume_env, monkeypatch): + # The choke point imports check_resume_provider locally from ..provider_guard + # at call time, so patch it at the source module. + monkeypatch.setattr( + "amplifier_app_cli.provider_guard.check_resume_provider", lambda *a, **kw: False + ) + + with pytest.raises(SystemExit) as exc_info: + session_module._prepare_resume_context( + resume_env, + lambda: [], + session_module.console, + ) + assert exc_info.value.code == 0 + + +def test_guard_proceed_returns_normal_tuple(resume_env, monkeypatch): + monkeypatch.setattr( + "amplifier_app_cli.provider_guard.check_resume_provider", lambda *a, **kw: True + ) + + result = session_module._prepare_resume_context( + resume_env, + lambda: [], + session_module.console, + ) + + assert result[0] == resume_env + assert len(result) == 8 diff --git a/tests/test_session_repair_cli.py b/tests/test_session_repair_cli.py new file mode 100644 index 0000000..8a9f036 --- /dev/null +++ b/tests/test_session_repair_cli.py @@ -0,0 +1,184 @@ +"""Tests for `amplifier session repair` / `amplifier session diagnose` (issue #208). + +Covers amplifier_app_cli.commands.session: +- _diagnose_session(): read-only health report +- _repair_session(): backup -> repair -> write -> self-verify (COMPLETE strategy) + +These run against the conftest-shadowed local amplifier-foundation checkout, +which (post lock-bump) matches the installed wheel -- both expose the repair +API used here (session_info, diagnose_transcript, repair_transcript, etc.). + +A "broken" transcript is an assistant message with a tool_calls entry and no +following tool-role message with a matching tool_call_id (orphaned tool_use -> +"missing_tool_results" failure mode). +""" + +from __future__ import annotations + +import json + +import pytest +from amplifier_foundation.session import TRANSCRIPT_FILENAME +from amplifier_foundation.session import diagnose_transcript +from amplifier_foundation.session import load_transcript_with_lines + +from amplifier_app_cli.commands.session import _diagnose_session +from amplifier_app_cli.commands.session import _repair_session +from amplifier_app_cli.session_store import SessionStore + + +def _seed( + base_dir, session_id: str, transcript: list[dict], metadata: dict | None = None +): + session_dir = base_dir / session_id + session_dir.mkdir(parents=True) + (session_dir / "metadata.json").write_text( + json.dumps(metadata or {"session_id": session_id, "bundle": "foundation"}), + encoding="utf-8", + ) + lines = "\n".join(json.dumps(e) for e in transcript) + (session_dir / TRANSCRIPT_FILENAME).write_text( + lines + ("\n" if lines else ""), encoding="utf-8" + ) + return session_dir + + +BROKEN_TRANSCRIPT = [ + {"role": "user", "content": "run a command"}, + { + "role": "assistant", + "content": "", + "tool_calls": [{"id": "toolu_orphan1", "function": {"name": "bash"}}], + }, +] + +HEALTHY_TRANSCRIPT = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi there"}, +] + + +def _capture(monkeypatch): + """Patch console.print to capture rendered output as a list of strings.""" + printed: list[str] = [] + import amplifier_app_cli.commands.session as session_module + + monkeypatch.setattr( + session_module.console, + "print", + lambda *a, **k: printed.append(" ".join(str(x) for x in a)), + ) + return printed + + +def test_repair_broken_makes_healthy(tmp_path, monkeypatch): + printed = _capture(monkeypatch) + session_id = "abcdef1234567890" + session_dir = _seed(tmp_path, session_id, BROKEN_TRANSCRIPT) + store = SessionStore(base_dir=tmp_path) + + _repair_session(store, session_id) + + entries = load_transcript_with_lines(session_dir) + verify = diagnose_transcript(entries) + assert verify["status"] == "healthy" + + backups = list(session_dir.glob(f"{TRANSCRIPT_FILENAME}.bak-repair-*")) + assert len(backups) == 1 + + text = "\n".join(printed) + assert "Repaired" in text + assert "Verified" in text + + +def test_repair_healthy_is_noop(tmp_path, monkeypatch): + printed = _capture(monkeypatch) + session_id = "healthy1234567890" + session_dir = _seed(tmp_path, session_id, HEALTHY_TRANSCRIPT) + store = SessionStore(base_dir=tmp_path) + + before = (session_dir / TRANSCRIPT_FILENAME).read_bytes() + _repair_session(store, session_id) + after = (session_dir / TRANSCRIPT_FILENAME).read_bytes() + + assert before == after + backups = list(session_dir.glob(f"{TRANSCRIPT_FILENAME}.bak-repair-*")) + assert backups == [] + + text = "\n".join(printed) + assert "already healthy" in text + + +def test_repair_partial_id(tmp_path, monkeypatch): + printed = _capture(monkeypatch) + session_id = "abcdef1234567890" + _seed(tmp_path, session_id, BROKEN_TRANSCRIPT) + store = SessionStore(base_dir=tmp_path) + + _repair_session(store, "abcdef") + + text = "\n".join(printed) + assert "Repaired" in text + + +def test_repair_missing_session_errors(tmp_path, monkeypatch): + _capture(monkeypatch) + store = SessionStore(base_dir=tmp_path) + + with pytest.raises(SystemExit) as exc_info: + _repair_session(store, "nope") + + assert exc_info.value.code == 1 + + +def test_diagnose_reports_broken(tmp_path, monkeypatch): + printed = _capture(monkeypatch) + session_id = "brokendiag12345678" + _seed(tmp_path, session_id, BROKEN_TRANSCRIPT) + store = SessionStore(base_dir=tmp_path) + + _diagnose_session(store, session_id) + + text = "\n".join(printed) + assert "broken" in text + assert "missing_tool_results" in text + assert "amplifier session repair" in text + + +def test_diagnose_reports_healthy(tmp_path, monkeypatch): + printed = _capture(monkeypatch) + session_id = "healthydiag1234567" + _seed(tmp_path, session_id, HEALTHY_TRANSCRIPT) + store = SessionStore(base_dir=tmp_path) + + _diagnose_session(store, session_id) + + text = "\n".join(printed) + assert "healthy" in text + assert "no repair needed" in text + + +def test_import_guard_message(tmp_path, monkeypatch): + """When foundation lacks the repair API, a clean actionable error is shown.""" + session_id = "guardtest123456789" + _seed(tmp_path, session_id, HEALTHY_TRANSCRIPT) + store = SessionStore(base_dir=tmp_path) + printed = _capture(monkeypatch) + + import builtins + + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "amplifier_foundation.session": + raise ImportError("simulated stale foundation") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + + with pytest.raises(SystemExit) as exc_info: + _repair_session(store, session_id) + + assert exc_info.value.code == 1 + text = "\n".join(printed) + assert "uv lock --upgrade-package amplifier-foundation" in text diff --git a/uv.lock b/uv.lock index 27028c5..36a7c4c 100644 --- a/uv.lock +++ b/uv.lock @@ -72,7 +72,7 @@ wheels = [ [[package]] name = "amplifier-foundation" version = "1.0.0" -source = { git = "https://github.com/microsoft/amplifier-foundation?branch=main#91dc9dc0bfc3a3890a190214aa584f903461ae91" } +source = { git = "https://github.com/microsoft/amplifier-foundation?branch=main#37a257ad0437b72ffc14af9d854cc0652a3ef2a3" } dependencies = [ { name = "amplifier-core" }, { name = "pyyaml" },