from __future__ import annotations import json from dataclasses import asdict, replace from pathlib import Path import pytest from workbench import api as workbench_api from workbench import readers from workbench.api import MAX_CHAT_PROMPT_CHARS, WorkbenchApi from workbench.journal import TurnJournal, TurnJournalEntry from workbench.schemas import ( ChatTurnResult, CognitivePipelineEdge, CognitivePipelineRecord, CognitivePipelineStage, TurnVerdict, ) def _pipeline_record(trace_hash: str = "sha256:trace") -> CognitivePipelineRecord: stages = [ CognitivePipelineStage( stage_id=stage_id, label=stage_id, status="recorded", summary=stage_id, detail={}, ) for stage_id in ( "input", "intent", "proposition_graph", "articulation_target", "realizer", "walk_telemetry", "trace_hash", ) ] edges = [ CognitivePipelineEdge(from_stage="input", to_stage="intent"), CognitivePipelineEdge(from_stage="intent", to_stage="proposition_graph"), CognitivePipelineEdge( from_stage="proposition_graph", to_stage="articulation_target" ), CognitivePipelineEdge(from_stage="articulation_target", to_stage="realizer"), CognitivePipelineEdge(from_stage="realizer", to_stage="walk_telemetry"), CognitivePipelineEdge(from_stage="walk_telemetry", to_stage="trace_hash"), ] return CognitivePipelineRecord( schema_version="cognitive_pipeline_record_v1", status="recorded", missing_reason=None, trace_hash=trace_hash, versor_condition=0.0, field_digest=None, stages=stages, edges=edges, ) def _chat_result(prompt: str = "What is truth?") -> ChatTurnResult: return ChatTurnResult( prompt=prompt, surface="Truth is coherent structure.", articulation_surface="Truth is coherent structure.", walk_surface="truth -> coherence", grounding_source="pack", epistemic_state="decoded", normative_clearance="cleared", normative_detail="", trace_hash="sha256:trace", refusal_emitted=False, hedge_injected=False, mutation_mode="runtime_turn", identity_verdict=TurnVerdict(outcome="cleared", runtime_detail=""), safety_verdict=TurnVerdict(outcome="cleared", runtime_detail=""), ethics_verdict=TurnVerdict(outcome="cleared", runtime_detail=""), proposal_candidates=[], turn_cost_ms=7, checkpoint_emitted=False, pipeline_record=_pipeline_record(), ) def _entry(turn_id: int = 1, prompt: str = "What is truth?") -> TurnJournalEntry: result = replace(_chat_result(prompt), turn_id=turn_id) return TurnJournalEntry.from_chat_turn( result, turn_id=turn_id, timestamp="2026-06-12T00:00:00+00:00", ) def _request(api: WorkbenchApi, method: str, path: str, body: dict | None = None): raw = b"" if body is None else json.dumps(body).encode("utf-8") return api.handle(method, path, raw) def _snapshot(root: Path) -> dict[str, bytes]: snap: dict[str, bytes] = {} if not root.exists(): return snap for path in sorted(root.rglob("*")): if path.is_file() and "__pycache__" not in path.relative_to(root).parts: snap[path.relative_to(root).as_posix()] = path.read_bytes() return snap def test_journal_appends_without_modifying_existing_entries(tmp_path: Path) -> None: journal = TurnJournal(tmp_path / "workbench_data") first = journal.append(_entry(1)) first_line = journal.path.read_text(encoding="utf-8").splitlines()[0] second = journal.append(_entry(2, "What is memory?")) lines = journal.path.read_text(encoding="utf-8").splitlines() assert len(lines) == 2 assert lines[0] == first_line assert first.journal_digest assert second.turn_id == 2 def test_journal_ordering_and_pagination_are_sequential(tmp_path: Path) -> None: journal = TurnJournal(tmp_path / "workbench_data") for turn_id in range(1, 5): journal.append(_entry(turn_id, f"prompt {turn_id}")) assert journal.next_turn_id() == 5 page = journal.list_summaries(limit=2, offset=1) assert [item.turn_id for item in page] == [2, 3] assert {item.trace_integrity for item in page} == {"pipeline_trace"} def test_run_detail_projects_verified_identity_continuity( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: engine_state = tmp_path / "engine_state" engine_state.mkdir() (engine_state / "manifest.json").write_text( json.dumps( { "schema_version": 2, "turn_count": 3, "written_at_revision": "rev-current", "engine_identity": "engine-abc", "parent_engine_identity": "engine-abc", "identity_scheme": 2, }, sort_keys=True, ), encoding="utf-8", ) monkeypatch.setattr(readers, "ENGINE_STATE_ROOT", engine_state) monkeypatch.setattr(readers, "get_git_revision", lambda: "rev-current") monkeypatch.setattr( readers, "engine_identity_for_config", lambda _config: "engine-abc" ) journal = TurnJournal(tmp_path / "workbench_data") detail = readers.read_run(readers.ENGINE_STATE_RUN_ID, journal) assert detail.identity_continuity is not None assert detail.identity_continuity.status == "verified" assert detail.identity_continuity.engine_identity == "engine-abc" assert detail.identity_continuity.current_engine_identity == "engine-abc" assert detail.identity_continuity.lineage_relation == "self_parent" assert detail.identity_continuity.evidence_gap is None def test_run_detail_projects_missing_identity_evidence_honestly( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: engine_state = tmp_path / "engine_state" engine_state.mkdir() (engine_state / "manifest.json").write_text( json.dumps( { "schema_version": 1, "turn_count": 2, "written_at_revision": "legacy-rev", }, sort_keys=True, ), encoding="utf-8", ) monkeypatch.setattr(readers, "ENGINE_STATE_ROOT", engine_state) monkeypatch.setattr(readers, "get_git_revision", lambda: "rev-current") monkeypatch.setattr( readers, "engine_identity_for_config", lambda _config: "engine-current" ) journal = TurnJournal(tmp_path / "workbench_data") detail = readers.read_run(readers.ENGINE_STATE_RUN_ID, journal) assert detail.identity_continuity is not None assert detail.identity_continuity.status == "missing_evidence" assert detail.identity_continuity.engine_identity is None assert detail.identity_continuity.current_engine_identity == "engine-current" assert detail.identity_continuity.lineage_relation == "unavailable" assert "predates L11" in (detail.identity_continuity.evidence_gap or "") def test_journal_classifies_old_hashless_rows_as_legacy(tmp_path: Path) -> None: journal = TurnJournal(tmp_path / "workbench_data") legacy = TurnJournalEntry.from_chat_turn( replace(_chat_result(), trace_hash=None), turn_id=1, timestamp="2026-06-12T00:00:00+00:00", ) payload = { key: value for key, value in asdict(legacy).items() if key != "trace_integrity" } journal.journal_dir.mkdir(parents=True, exist_ok=True) journal.path.write_text( json.dumps(payload, sort_keys=True) + "\n", encoding="utf-8" ) entry = journal.get_entry(1) summary = journal.list_summaries()[0] assert entry.trace_integrity == "legacy_unhashed" assert summary.trace_integrity == "legacy_unhashed" def test_trace_turns_offset_beyond_journal_length_returns_empty_items( tmp_path: Path, ) -> None: journal = TurnJournal(tmp_path / "workbench_data") journal.append(_entry(1)) api = WorkbenchApi(journal=journal) response = _request(api, "GET", "/trace/turns?limit=50&offset=50") assert response.status == 200 assert response.payload["data"]["items"] == [] def test_empty_journal_returns_empty_items(tmp_path: Path) -> None: api = WorkbenchApi(journal_dir=tmp_path / "workbench_data") response = _request(api, "GET", "/trace/turns") assert response.status == 200 assert response.payload["data"]["items"] == [] def test_prompt_size_limit_is_enforced_before_journaling(tmp_path: Path) -> None: api = WorkbenchApi(journal_dir=tmp_path / "workbench_data") prompt = "x" * (MAX_CHAT_PROMPT_CHARS + 1) response = _request(api, "POST", "/chat/turn", {"prompt": prompt}) assert response.status == 400 assert not (tmp_path / "workbench_data" / "turn_journal.jsonl").exists() def test_hashless_chat_result_is_not_journaled( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: def fake_run(prompt: str) -> ChatTurnResult: return replace(_chat_result(prompt), trace_hash=None) monkeypatch.setattr(workbench_api, "_run_chat_turn", fake_run) api = WorkbenchApi(journal_dir=tmp_path / "workbench_data") response = _request(api, "POST", "/chat/turn", {"prompt": "What is truth?"}) assert response.status == 500 assert response.payload["error"]["code"] == "runtime_unavailable" assert not api._journal.path.exists() # noqa: SLF001 - verifies fail-closed journaling. def test_journal_rejects_paths_outside_workbench_data(tmp_path: Path) -> None: with pytest.raises(ValueError, match="workbench_data"): TurnJournal(tmp_path / "not_workbench_data") def test_journal_does_not_write_teaching_pack_or_engine_state_roots( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: repo_root = Path(__file__).resolve().parent.parent guarded = { "teaching": repo_root / "teaching", "packs": repo_root / "packs", "language_packs/data": repo_root / "language_packs" / "data", "engine_state": repo_root / "engine_state", } before = {name: _snapshot(path) for name, path in guarded.items()} def fake_run(prompt: str) -> ChatTurnResult: return _chat_result(prompt) monkeypatch.setattr(workbench_api, "_run_chat_turn", fake_run) api = WorkbenchApi(journal_dir=tmp_path / "workbench_data") response = _request(api, "POST", "/chat/turn", {"prompt": "What is truth?"}) assert response.status == 200 assert {name: _snapshot(path) for name, path in guarded.items()} == before assert api._journal.path.exists() # noqa: SLF001 - verifies the configured boundary. def test_journal_digest_is_deterministic_for_identical_content(tmp_path: Path) -> None: journal = TurnJournal(tmp_path / "workbench_data") first = journal.append(_entry(1)) other_journal = TurnJournal(tmp_path / "other" / "workbench_data") second = other_journal.append(_entry(1)) assert first.journal_digest == second.journal_digest def test_chat_turn_round_trips_through_trace_endpoint( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: def fake_run(prompt: str) -> ChatTurnResult: return _chat_result(prompt) monkeypatch.setattr(workbench_api, "_run_chat_turn", fake_run) api = WorkbenchApi(journal_dir=tmp_path / "workbench_data") chat = _request(api, "POST", "/chat/turn", {"prompt": "What is truth?"}) turn_id = chat.payload["data"]["turn_id"] trace = _request(api, "GET", f"/trace/{turn_id}") assert chat.status == 200 assert trace.status == 200 assert trace.payload["data"]["trace_integrity"] == "pipeline_trace" for field in [ "turn_id", "prompt", "surface", "articulation_surface", "walk_surface", "trace_hash", "grounding_source", "epistemic_state", "normative_clearance", "refusal_emitted", "hedge_injected", "proposal_candidates", "turn_cost_ms", "checkpoint_emitted", ]: assert trace.payload["data"][field] == chat.payload["data"][field] assert trace.payload["data"]["pipeline_record"]["status"] == "recorded" def test_trace_pipeline_endpoint_projects_recorded_stage_evidence( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: def fake_run(prompt: str) -> ChatTurnResult: return _chat_result(prompt) monkeypatch.setattr(workbench_api, "_run_chat_turn", fake_run) api = WorkbenchApi(journal_dir=tmp_path / "workbench_data") chat = _request(api, "POST", "/chat/turn", {"prompt": "What is truth?"}) turn_id = chat.payload["data"]["turn_id"] pipeline = _request(api, "GET", f"/trace/{turn_id}/pipeline") assert pipeline.status == 200 data = pipeline.payload["data"] assert data["schema_version"] == "cognitive_pipeline_record_v1" assert data["status"] == "recorded" assert [stage["stage_id"] for stage in data["stages"]] == [ "input", "intent", "proposition_graph", "articulation_target", "realizer", "walk_telemetry", "trace_hash", ] def test_trace_pipeline_endpoint_marks_prewidening_rows_missing_evidence( tmp_path: Path, ) -> None: journal = TurnJournal(tmp_path / "workbench_data") legacy = replace(_entry(1), pipeline_record=None) journal.append(legacy) api = WorkbenchApi(journal=journal) response = _request(api, "GET", "/trace/1/pipeline") assert response.status == 200 assert response.payload["data"]["status"] == "missing_evidence" assert response.payload["data"]["missing_reason"] == "pipeline_record_not_persisted" assert response.payload["data"]["trace_hash"] == legacy.trace_hash def test_chat_turn_without_pipeline_record_is_not_journaled( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: def fake_run(prompt: str) -> ChatTurnResult: return replace(_chat_result(prompt), pipeline_record=None) monkeypatch.setattr(workbench_api, "_run_chat_turn", fake_run) api = WorkbenchApi(journal_dir=tmp_path / "workbench_data") response = _request(api, "POST", "/chat/turn", {"prompt": "What is truth?"}) assert response.status == 500 assert response.payload["error"]["code"] == "runtime_unavailable" assert not api._journal.path.exists() # noqa: SLF001 - verifies fail-closed journaling. def test_unknown_turn_returns_404(tmp_path: Path) -> None: api = WorkbenchApi(journal_dir=tmp_path / "workbench_data") missing = _request(api, "GET", "/trace/999") invalid = _request(api, "GET", "/trace/not-a-real-turn") assert missing.status == 404 assert invalid.status == 404