diff --git a/tests/test_anthropic_native_custody.py b/tests/test_anthropic_native_custody.py index e21ef7253..b2e71af75 100644 --- a/tests/test_anthropic_native_custody.py +++ b/tests/test_anthropic_native_custody.py @@ -6,7 +6,6 @@ import copy import hashlib import json import pathlib -from types import SimpleNamespace import pytest @@ -512,67 +511,3 @@ def test_main_preserves_private_receipt_but_persists_only_public_projection( assert all("opaque thought" not in json.dumps(item) for item in persisted) assert all(ANTHROPIC_NATIVE_RECEIPT_KEY not in json.dumps(item) for item in persisted) - -def test_background_preserves_receipt_and_aggregates_round_disclosures( - tmp_path, monkeypatch, -): - import ouroboros.consciousness as consciousness - import ouroboros.llm_observability as observed - - native = _canonical_message() - seen_messages = [] - events = [] - responses = [ - (native, { - "cost": 0.0, - "cost_final": True, - "request_wire": {"attempt_id": "a", "candidate_sha256": "sha-a"}, - }), - ({"role": "assistant", "content": "done"}, { - "cost": 0.0, - "cost_final": True, - "request_wire": {"attempt_id": "b", "candidate_sha256": "sha-b"}, - }), - ] - - def chat_observed(_llm, **kwargs): - seen_messages.append(copy.deepcopy(kwargs["messages"])) - return responses[len(seen_messages) - 1] - - monkeypatch.setattr(observed, "chat_observed", chat_observed) - monkeypatch.setattr(consciousness, "get_consciousness_model", lambda: "anthropic/claude-future") - monkeypatch.setattr( - consciousness, - "append_jsonl", - lambda _path, row: (events.append(row) or True), - ) - monkeypatch.setattr(consciousness.BackgroundConsciousness, "_build_context", lambda _self: "ctx") - monkeypatch.setattr(consciousness.BackgroundConsciousness, "_tool_schemas", lambda _self: []) - monkeypatch.setattr(consciousness.BackgroundConsciousness, "_check_budget", lambda _self: True) - monkeypatch.setattr(consciousness.BackgroundConsciousness, "_emit_live_log", lambda *_a, **_k: None) - monkeypatch.setattr(consciousness.BackgroundConsciousness, "_emit_progress", lambda *_a: None) - monkeypatch.setattr( - consciousness.BackgroundConsciousness, - "_execute_tool", - lambda _self, _call, _events, _validation: "tool-result", - ) - - bg = object.__new__(consciousness.BackgroundConsciousness) - bg._drive_root = tmp_path - bg._llm = SimpleNamespace(_resolve_remote_target=lambda _model: { - "provider": "anthropic", - "resolved_model": "claude-future", - "usage_model": "anthropic/claude-future", - "base_url": "https://api.anthropic.com", - }) - bg._max_bg_rounds = 2 - bg._paused = False - bg._event_queue = None - bg._bg_spent_usd = 0.0 - bg._last_idle_reason = "" - bg._next_wakeup_sec = 1.0 - assert bg._think_scoped() is True - assert ANTHROPIC_NATIVE_RECEIPT_KEY in seen_messages[1][2] - thought = next(row for row in events if row.get("type") == "consciousness_thought") - assert thought["request_wire"]["attempt_id"] == "b" - assert [item["attempt_id"] for item in thought["request_wire_history"]] == ["a", "b"] diff --git a/tests/test_background_model_wait.py b/tests/test_background_model_wait.py index 6e3129a07..5a9d1361f 100644 --- a/tests/test_background_model_wait.py +++ b/tests/test_background_model_wait.py @@ -1,195 +1,22 @@ -"""Real background loop and model-wait controls over a controlled model engine.""" +"""Model-wait facts that survive the Background Consciousness redesign: an owner +without a task ceiling keeps lexical deadlines, an image window is not a cycle +deadline, and an auth wait preserves the profile intent. (A wake-up is an ordinary +Main turn now; its model waits are the direct turn's own — see test_model_wait.)""" -from copy import deepcopy import json -import queue -import threading -import time -from types import SimpleNamespace import pytest from ouroboros import config, model_wait from ouroboros import llm_claudexor as transport -from ouroboros.consciousness import BackgroundConsciousness -from ouroboros.gateway import task_model_wait as gateway -from tests.test_llm_claudexor import Gateway, MODEL, result, ledger +from tests.test_llm_claudexor import MODEL -def until(predicate, timeout=5): - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - if predicate(): - return - time.sleep(0.005) - raise AssertionError("controlled background state did not arrive") - - -@pytest.fixture -def background(tmp_path, monkeypatch): - root = tmp_path / "data" - monkeypatch.setenv("OUROBOROS_DATA_DIR", str(root)) - monkeypatch.setenv("OUROBOROS_SETTINGS_PATH", str(root / "settings.json")) - monkeypatch.setenv("TOTAL_BUDGET", "100") - monkeypatch.setenv("OUROBOROS_MODEL_CONSCIOUSNESS", MODEL) - monkeypatch.setattr(config, "CLAUDEXOR_MODEL_POLL_INTERVAL_SEC", 0.005) - monkeypatch.setattr(config, "NETWORK_WAIT_BACKOFF_START_SEC", 0.005) - monkeypatch.setattr(config, "NETWORK_WAIT_BACKOFF_MAX_SEC", 0.01) - registry = SimpleNamespace(_ctx=SimpleNamespace(task_metadata={}), - get_timeout=lambda _name: 10, schemas=lambda: []) - tools = [] - registry.execute = lambda name, args: tools.append((name, args)) or "read completed once" - from ouroboros.tools.tool_result import ToolResult - registry.execute_result = lambda name, args: ( - tools.append((name, args)) - or ToolResult(status="ok", code="OK", text="read completed once", meta={"source": "fixture"}) - ) - monkeypatch.setattr(BackgroundConsciousness, "_build_registry", lambda self: registry) - events = queue.Queue() - bc = BackgroundConsciousness(root, tmp_path / "repo", events, lambda: 1) - monkeypatch.setattr(bc, "_build_context", lambda **kwargs: "Own background context") - monkeypatch.setattr(bc, "_tool_schemas", lambda: []) - engine = Gateway() - first = result() - first["message"] = {"role": "assistant", "content": "read first", "tool_calls": [ - {"id": "read-once", "type": "function", "function": {"name": "read_file", "arguments": '{"path":"file"}'}}]} - refusal = result(outcome="failed", problem={"code": "subscription_window_exhausted", "message": "controlled quota"}) - final = result() - final["message"] = {"role": "assistant", "content": "Cycle truly finished"} - engine.results = [first, refusal, final] - engine.dispatch = ["response_received", "not_started", "response_received"] - monkeypatch.setattr(transport, "ensure_owned_gateway", lambda: engine) - ready = threading.Event() - metadata_ready = threading.Event() - monkeypatch.setattr(bc._llm, "claudexor_model_sources", lambda: { - "sources": [{"id": "codex", "credentialHarness": "fixture"}]}) - - def catalog(*_, **_kw): - # Source discovery publishes a second revision before this read. Tests - # need that stable snapshot before exercising the real decision ingress. - metadata_ready.set() - return {"source": "codex", "models": [{"id": "exact-model"}] if ready.is_set() else []} - - monkeypatch.setattr(bc._llm, "claudexor_model_catalog", catalog) - bc.inject_observation("one pending observation", observation_id="one") - outcomes, failures = [], [] - - def run(): - try: - outcomes.append(bc._think()) - except BaseException as error: - failures.append(error) - - def start(): - bc._running = True - bc._stop_event.clear() - metadata_ready.clear() - bc._thread = threading.Thread(target=run) - bc._thread.start() - return bc._thread - - yield SimpleNamespace(bc=bc, engine=engine, ready=ready, metadata_ready=metadata_ready, events=events, tools=tools, - outcomes=outcomes, failures=failures, root=root, start=start, - decide=lambda body: gateway._decide(root, body, get_background_model_wait=bc.live_model_wait)) - bc._stop_event.set() - bc._wakeup_event.set() - if bc._thread: - bc._thread.join(5) - assert not bc._thread.is_alive() - bc._tool_executor.shutdown(wait=True, cancel_futures=True) - - -def waiting(fixture): - if not fixture.metadata_ready.is_set(): - return None - rows = fixture.bc.model_wait_snapshot()["model_waits"] - return next((row for row in rows.values() if row["state"] == "waiting"), None) - - -def decision(row, action="retry", **fields): - return {"request_id": "owner-choice", "decision_id": f"model_wait:bg-consciousness:{row['wait_id']}", - "revision": row["revision"], "action": action, **fields} - - -@pytest.mark.parametrize("code", ["subscription_window_exhausted", "auth_required"]) -def test_background_quota_holds_exact_cycle_without_task_record(background, code): - f = background - f.engine.results[1]["problem"]["code"] = code - thread = f.start() - until(lambda: waiting(f)) - owner = f.bc.live_model_wait() - assert owner.execution_window_remaining() is None - assert waiting(f)["worker_slot_held"] is False - assert waiting(f)["reason"] == ("auth" if code == "auth_required" else "quota") - assert f.tools == [("read_file", {"path": "file"})] - assert f.outcomes == [] and len(f.bc._snapshot_pending_observations()) == 1 - f.ready.set() - thread.join(5) - assert f.failures == [] and f.outcomes == [True] - assert len(f.tools) == 1 and len(f.engine.creates) == 3 - assert f.bc._snapshot_pending_observations() == [] - assert not (f.root / "task_results" / "bg-consciousness.json").exists() - assert owner.closed and f.bc.live_model_wait() is None - - -def test_foreground_pause_after_quota_preserves_old_call_until_resume(background): - f = background - thread = f.start() - until(lambda: waiting(f)) - f.bc.pause() - f.ready.set() - until(lambda: not waiting(f)) - assert thread.is_alive() and f.outcomes == [] - assert len(f.engine.creates) == 2 and len(f.tools) == 1 - f.bc.resume() - thread.join(5) - assert f.failures == [] and f.outcomes == [True] - assert len(f.engine.creates) == 3 and len(f.tools) == 1 - - -def test_stop_during_model_wait_keeps_observations_unacknowledged(background): - f = background - thread = f.start() - until(lambda: waiting(f)) - owner = f.bc.live_model_wait() - f.bc.stop() - thread.join(5) - assert f.failures == [] and f.outcomes == [False] - assert f.bc._last_idle_reason == "stopped" and owner.closed - assert len(f.engine.creates) == 2 and len(f.bc._snapshot_pending_observations()) == 1 - assert not (f.root / "task_results" / "bg-consciousness.json").exists() - - -def test_unknown_outcome_is_not_waitable_or_free(background): - f = background - f.engine.results[1] = result(outcome="unknown", problem={"code": "provider_unavailable", "message": "unknown"}) - f.engine.dispatch[1] = "unknown" - f.start().join(5) - assert f.failures == [] and f.outcomes == [False] - assert len(f.engine.creates) == 2 and len(f.tools) == 1 - assert ledger(f.root)[-1]["state"] == "unresolved" - assert len(f.bc._snapshot_pending_observations()) == 1 - - -def test_stop_of_inflight_background_operation_cancels_same_id_once(background): - f = background - f.engine.pending = True - thread = f.start() - until(lambda: bool(f.engine.reads)) - f.bc.stop() - thread.join(5) - assert f.failures == [] and f.outcomes == [False] - assert f.bc._last_idle_reason == "stopped" - assert len(f.engine.creates) == 1 and f.engine.cancels == [("op-0", "host_cancelled")] - assert ledger(f.root)[-1]["state"] == "unresolved" - assert len(f.bc._snapshot_pending_observations()) == 1 - - -def test_image_window_fallback_does_not_create_background_cycle_deadline(tmp_path, monkeypatch): +def test_image_window_fallback_does_not_create_a_ceilingless_owner_deadline(tmp_path, monkeypatch): from ouroboros.tools.vision import _vision_execution_window monkeypatch.setattr(config, "get_task_abs_ceiling_sec", lambda: 900) - with model_wait.task_model_wait_scope(task={"id": "background"}, drive_root=tmp_path, + with model_wait.task_model_wait_scope(task={"id": "ceilingless"}, drive_root=tmp_path, event_queue=None, worker_slot_held=False, owner_control=lambda: None) as owner: owner.started_monotonic = 0 assert owner.execution_window_remaining() is None @@ -197,13 +24,13 @@ def test_image_window_fallback_does_not_create_background_cycle_deadline(tmp_pat assert owner.control_reason() is None -def test_background_owner_retains_lexical_deadlines_without_task_ceiling(tmp_path, monkeypatch): - owner = model_wait.TaskModelWait(task={"id": "bg-consciousness"}, drive_root=tmp_path, +def test_ceilingless_owner_retains_lexical_deadlines_without_task_ceiling(tmp_path, monkeypatch): + owner = model_wait.TaskModelWait(task={"id": "ceilingless"}, drive_root=tmp_path, event_queue=None, worker_slot_held=False, owner_control=lambda: None) owner.started_monotonic = 0 monkeypatch.setattr(config, "get_task_abs_ceiling_sec", lambda: 0) assert owner.control_reason() is None and owner.execution_window_remaining() is None - with model_wait.task_model_wait_scope(task={"id": "bg-consciousness"}, drive_root=tmp_path, + with model_wait.task_model_wait_scope(task={"id": "ceilingless"}, drive_root=tmp_path, event_queue=None, worker_slot_held=False, owner_control=lambda: None) as bound: with model_wait.execution_deadline_scope(model_wait.monotonic_now() - 1): assert bound.control_reason() == "execution_deadline" @@ -211,129 +38,6 @@ def test_background_owner_retains_lexical_deadlines_without_task_ceiling(tmp_pat assert bound.control_reason() == "deadline" -def test_owner_switch_is_same_live_map_and_old_cycle_action_is_refused(background): - f = background - thread = f.start() - until(lambda: waiting(f)) - row = deepcopy(waiting(f)) - owner = f.bc.live_model_wait() - owned_row = owner.waits[row["wait_id"]] - response = f.decide(decision(row, "switch", model=MODEL, credential_profile_id="replacement", - use_local=False, persist_role=False)) - assert response.status_code == 202 - assert owner.waits[row["wait_id"]] is owned_row - thread.join(5) - assert f.failures == [] and f.outcomes == [True] - assert f.engine.uploads[-1][0]["account"] == {"mode": "pin", "profileId": "replacement"} - assert f.decide(decision(row)).status_code == 409 - assert not (f.root / "settings.json").exists() - - -def test_persistent_background_switch_saves_only_consciousness(background, monkeypatch): - f = background - path = f.root / "settings.json" - monkeypatch.setattr(config, "SETTINGS_PATH", path) - path.write_text(json.dumps({"OUROBOROS_MODEL": "main-stays", "OUROBOROS_MODEL_LIGHT": "light-stays"})) - thread = f.start() - until(lambda: waiting(f)) - response = f.decide(decision(deepcopy(waiting(f)), "switch", model=MODEL, - credential_profile_id="replacement", use_local=False, persist_role=True)) - assert response.status_code == 202 and json.loads(response.body)["saved"] is True - thread.join(5) - saved = json.loads(path.read_text()) - assert saved["OUROBOROS_MODEL"] == "main-stays" and saved["OUROBOROS_MODEL_LIGHT"] == "light-stays" - assert saved["OUROBOROS_MODEL_CONSCIOUSNESS"] == MODEL - assert json.loads(saved["OUROBOROS_MODEL_ACCOUNTS"])["consciousness"] == "replacement" - assert f.outcomes == [True] and not f.failures - - -@pytest.mark.parametrize("phase", ["before", "after"]) -def test_current_background_owner_fences_settings_after_live_owner_changes(background, monkeypatch, phase): - from ouroboros.gateway import owner_settings - - f = background - monkeypatch.setattr(config, "SETTINGS_PATH", f.root / "settings.json") - thread = f.start() - until(lambda: waiting(f)) - old_owner = f.bc.live_model_wait() - original = owner_settings._owner_update_settings - - def stale_before_write(transform, **kwargs): - if phase == "before": - f.bc._model_wait = None - value = original(transform, **kwargs) - f.bc._model_wait = None - return value - - monkeypatch.setattr(owner_settings, "_owner_update_settings", stale_before_write) - response = f.decide(decision(deepcopy(waiting(f)), "switch", model=MODEL, - credential_profile_id="replacement", use_local=False, persist_role=True)) - assert response.status_code == 409 and json.loads(response.body)["saved"] is (phase == "after") - assert (f.root / "settings.json").exists() is (phase == "after") - f.bc._model_wait = old_owner - f.bc.stop() - thread.join(5) - - -def test_background_wait_forwarding_and_reload_use_existing_owner(background): - from ouroboros.gateway.history import _assemble_history_response - from ouroboros.utils import append_jsonl - from supervisor.task_model_wait import handle_task_model_wait - - f = background - thread = f.start() - until(lambda: (waiting(f) or {}).get("revision")) - owner = f.bc.live_model_wait() - # Source metadata can publish a newer revision while the cycle waits. - # Keep this snapshot current throughout forwarding and reload assertions. - with owner.lock: - row = deepcopy(waiting(f)) - event = {"type": "task_model_wait", "task_id": "bg-consciousness", "ts": "2026-09-07T00:00:00Z", **row} - forwarded = [] - ctx = SimpleNamespace(RUNNING={}, DRIVE_ROOT=f.root, consciousness=f.bc, - append_jsonl=append_jsonl, bridge=SimpleNamespace(push_log=forwarded.append)) - handle_task_model_wait(event, ctx) - assert len(forwarded) == 1 and forwarded[0]["chat_id"] == 1 - handle_task_model_wait({**event, "model_wait_owner_id": "previous-cycle"}, ctx) - assert len(forwarded) == 1 and ctx.RUNNING == {} - append_jsonl(f.root / "logs" / "progress.jsonl", {"task_id": "bg-consciousness", - "type": "send_message", "is_progress": True, "ts": "2026-09-07T00:00:00Z", "text": "Earlier thought"}) - payload = json.loads(_assemble_history_response(f.root, 1, 10, 10, owner.snapshot())) - current = payload["messages"][-1] - assert current["model_wait_live"] and current["model_wait_owner_id"] == owner.owner_id - assert not any(row.get("task_terminal_status") for row in payload["messages"] if row.get("is_progress")) - f.bc._owner_chat_id_fn = lambda: 0 - handle_task_model_wait(event, ctx) - assert forwarded[-1]["chat_id"] == 0 - hidden = json.loads(_assemble_history_response(f.root, 1, 10, 10, {**owner.snapshot(), "chat_id": 0})) - assert not any(row.get("model_wait_live") for row in hidden["messages"]) - assert any(row.get("task_terminal_status") == "done" for row in hidden["messages"] if row.get("is_progress")) - f.bc.stop() - thread.join(5) - - -def test_previous_cycle_decision_cannot_target_a_new_live_cycle(background): - f = background - thread = f.start() - until(lambda: waiting(f)) - previous = deepcopy(waiting(f)) - previous_owner = f.bc.live_model_wait() - f.ready.set() - thread.join(5) - f.ready.clear() - f.engine.results.extend(deepcopy(f.engine.results)) - f.engine.dispatch.extend(list(f.engine.dispatch)) - thread = f.start() - until(lambda: waiting(f)) - current = f.bc.live_model_wait() - assert current is not previous_owner and current.owner_id != previous_owner.owner_id - assert f.decide(decision(previous)).status_code == 404 - assert "pending_action" not in waiting(f) - assert len(f.engine.creates) == 5 - f.bc.stop() - thread.join(5) - - @pytest.mark.parametrize("override,problem_context,expected", [ (None, {}, "configured-pin"), ("", {}, ""), (None, {"credentialProfileId": "proved-profile"}, "proved-profile"), @@ -351,25 +55,3 @@ def test_auth_wait_profile_hint_preserves_intent_without_inventing_route(tmp_pat row, = waiter.waits.values() assert row["credential_profile_id"] == expected assert error.route == {} # The UI hint is not claimed as an actual provider route. - - -@pytest.mark.serial -@pytest.mark.parametrize("surface", ["web", "host"]) -def test_real_background_owner_is_shared_by_both_decision_transports(background, surface): - from tests.test_model_wait import _decision_clients - - f = background - thread = f.start() - until(lambda: waiting(f)) - row = deepcopy(waiting(f)) - owner = f.bc.live_model_wait() - with _decision_clients(f.root, f.bc.live_model_wait) as clients: - response = clients[surface](decision(row, "switch", model=MODEL, - credential_profile_id="replacement", use_local=False, persist_role=False)) - assert response.status_code == 202 and response.json()["saved"] is False - thread.join(5) - assert f.failures == [] and f.outcomes == [True] and owner.closed - assert f.engine.uploads[-1][0]["account"] == {"mode": "pin", "profileId": "replacement"} - stale = clients[surface](decision(row)) - assert stale.status_code == 409 and stale.json()["reason_code"] == "task_not_live" - assert not (f.root / "task_results" / "bg-consciousness.json").exists() diff --git a/tests/test_bugfixes_airi.py b/tests/test_bugfixes_airi.py index fdc11c1b3..5fe76ec58 100644 --- a/tests/test_bugfixes_airi.py +++ b/tests/test_bugfixes_airi.py @@ -34,13 +34,6 @@ def _read(rel: str) -> str: # ───────────────── Bug 1 (polish): background card finalizes ───────────────── -def test_consciousness_emits_structured_idle_marker_not_text_matched(): - src = _read("ouroboros/consciousness.py") - assert "_emit_cycle_idle" in src - assert "consciousness_state" in src - # The marker is structured, never a regex on log text (BIBLE P5). - assert "Going back to sleep" not in src - def test_log_events_derives_bg_card_phase_from_marker(): src = _read("web/modules/log_events.js") diff --git a/tests/test_chat_inject_attachments.py b/tests/test_chat_inject_attachments.py index eb5cc15b1..b28b77fda 100644 --- a/tests/test_chat_inject_attachments.py +++ b/tests/test_chat_inject_attachments.py @@ -202,22 +202,19 @@ def test_decision_route_relays_to_the_one_decision_ingress(tmp_path, monkeypatch captured = {} - async def fake_answer(drive_root, body, *, get_background_model_wait=None): + async def fake_answer(drive_root, body): captured["drive_root"] = drive_root captured["body"] = body - captured["get_background_model_wait"] = get_background_model_wait return 200, {"ok": True, "decision_id": body["decision_id"], "state": "answered", "answered_index": 1} monkeypatch.setattr(td, "answer_decision", fake_answer) client = _client(tmp_path, FakeBridge()) - getter = lambda: None - client.app.state.get_background_model_wait = getter body = {"request_id": "tg:99", "decision_id": "quiz:task-1:q1", "option_index": 1} response = client.post("/chat/decision", headers={"X-Skill-Token": "token"}, json=body) assert response.status_code == 200 assert response.json() == {"ok": True, "decision_id": "quiz:task-1:q1", "state": "answered", "answered_index": 1} - assert captured == {"drive_root": tmp_path, "body": body, "get_background_model_wait": getter} + assert captured == {"drive_root": tmp_path, "body": body} def test_decision_route_returns_the_ingress_refusals_verbatim(tmp_path): @@ -236,7 +233,7 @@ def test_decision_route_returns_the_ingress_refusals_verbatim(tmp_path): def test_decision_route_is_rate_limited_per_skill(tmp_path, monkeypatch): import ouroboros.gateway.task_decision as td - async def fake_answer(drive_root, body, *, get_background_model_wait=None): + async def fake_answer(drive_root, body): return 200, {"ok": True} monkeypatch.setattr(td, "answer_decision", fake_answer) diff --git a/tests/test_consciousness.py b/tests/test_consciousness.py index 7f875611e..b2a6283c2 100644 --- a/tests/test_consciousness.py +++ b/tests/test_consciousness.py @@ -1,318 +1,378 @@ +"""The alarm clock of Background Consciousness (redesign P2): ``tick`` decides WHEN an +ordinary Main turn starts on its own; the direct-activity census is the only liveness +truth; ``notify`` debounces early wakes arithmetically; a wake's own finish never +re-arms it. The lane that runs the turn is pinned in ``test_consciousness_wake_lane.py``. """ -Tests for BackgroundConsciousness helpers. -Verifies progress events have the correct shape, reach the queue, -and respect pause / chat_id=None semantics. Also covers backlog digest -inclusion in background context. - -Run: pytest tests/test_consciousness.py -v -""" +from __future__ import annotations import json -import os -import pathlib -import queue -import sys -import tempfile -import unittest -from unittest.mock import MagicMock, patch +from types import SimpleNamespace -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) +import pytest + +from ouroboros import consciousness as clock_module +from ouroboros.consciousness import ( + ARCHIVED_INBOX_REL, INTERVAL_STATE_KEY, LEGACY_INBOX_REL, NEXT_WAKE_STATE_KEY, BackgroundConsciousness, +) +from supervisor.active_activity import get_direct_activity_registry + +T0 = 1_800_000_000.0 +FLOOR, CEILING, DEFAULT = 900, 14400, 3300 +AVAILABLE = {"status": "available", "limit_usd": 20.0, "accounted_usd": 2.5, "remaining_usd": 17.5, + "resets_at": "", "unknown_unmetered": 0} -class TestEmitProgress(unittest.TestCase): - """Tests for BackgroundConsciousness._emit_progress.""" +@pytest.fixture +def clock(monkeypatch, tmp_path): + """A constructed alarm clock over an in-memory runtime state and a stubbed lane.""" + from supervisor import state, workers - def _make_consciousness(self, chat_id=42, event_queue=None): - """Create a BackgroundConsciousness with mocked dependencies.""" - from ouroboros.consciousness import BackgroundConsciousness + store = {"bg_consciousness_enabled": True, "owner_chat_id": 7} + monkeypatch.setattr(state, "load_state", lambda: dict(store)) - tmpdir = tempfile.mkdtemp() - drive_root = pathlib.Path(tmpdir) - (drive_root / "logs").mkdir(parents=True, exist_ok=True) - repo_dir = pathlib.Path(tmpdir) / "repo" - repo_dir.mkdir() + def update_state(mutator): + mutator(store) + return dict(store) - eq = event_queue if event_queue is not None else queue.Queue() + monkeypatch.setattr(state, "update_state", update_state) + monkeypatch.setenv("OUROBOROS_BG_WAKEUP_MIN", str(FLOOR)) + monkeypatch.setenv("OUROBOROS_BG_WAKEUP_MAX", str(CEILING)) + monkeypatch.setenv("OUROBOROS_CONSCIOUSNESS_AUTONOMY", "act") + monkeypatch.setenv("OUROBOROS_PER_TASK_COST_USD", "50") + monkeypatch.delenv("OUROBOROS_CONSCIOUSNESS_MAX_TASKS", raising=False) + (tmp_path / "logs").mkdir() + (tmp_path / "repo" / "prompts").mkdir(parents=True) + (tmp_path / "repo" / "prompts" / "CONSCIOUSNESS.md").write_text( + "[Wake-up · {reason}] since {last_wake_ago}: {events} level={level} withheld={withheld_tools} " + "spent={spent_usd}/{daily_usd} running={running}/{max_tasks} interval={interval} line={level_line}", + encoding="utf-8") + launches: list = [] + receipt = {"admitted": True, "task_id": "wake0001", "reason": ""} - with patch.object(BackgroundConsciousness, '_build_registry', return_value=MagicMock()): - bc = BackgroundConsciousness( - drive_root=drive_root, - repo_dir=repo_dir, - event_queue=eq, - owner_chat_id_fn=lambda: chat_id, - ) - return bc, eq, drive_root + def handle_wake_direct(chat_id, text, task_metadata, on_finished=None): + launches.append({"chat_id": chat_id, "text": text, "metadata": task_metadata, "on_finished": on_finished}) + return dict(receipt) - def test_event_shape(self): - """Event has type, chat_id, text, is_progress, ts.""" - bc, eq, _ = self._make_consciousness(chat_id=99) - bc._emit_progress("thinking about things") - evt = eq.get_nowait() - - self.assertEqual(evt["type"], "send_message") - self.assertEqual(evt["chat_id"], 99) - self.assertEqual(evt["text"], "💬 thinking about things") - self.assertEqual(evt["format"], "markdown") - self.assertTrue(evt["is_progress"]) - self.assertIn("ts", evt) - - def test_empty_content_skipped(self): - """Empty or whitespace-only content produces no event.""" - bc, eq, drive_root = self._make_consciousness() - progress_path = drive_root / "logs" / "progress.jsonl" - - bc._emit_progress("") - bc._emit_progress(" ") - bc._emit_progress(None) - - self.assertTrue(eq.empty()) - # Also should not persist to file - self.assertFalse(progress_path.exists()) - - def test_chat_id_none_skips_queue_but_persists(self): - """When chat_id is None, event is NOT queued but IS persisted.""" - bc, eq, drive_root = self._make_consciousness(chat_id=None) - bc._emit_progress("background thought") - - # Queue should be empty - self.assertTrue(eq.empty()) - - # File should have the entry - progress_path = drive_root / "logs" / "progress.jsonl" - self.assertTrue(progress_path.exists()) - entry = json.loads(progress_path.read_text().strip()) - self.assertEqual(entry["type"], "send_message") - self.assertEqual(entry["content"], "background thought") - self.assertTrue(entry["is_progress"]) - - def test_paused_events_go_to_deferred(self): - """When paused, events go to _deferred_events, not the queue.""" - bc, eq, _ = self._make_consciousness() - bc._paused = True - bc._emit_progress("deferred thought") - - self.assertTrue(eq.empty()) - self.assertEqual(len(bc._deferred_events), 1) - self.assertEqual(bc._deferred_events[0]["type"], "send_message") - self.assertEqual(bc._deferred_events[0]["text"], "💬 deferred thought") + monkeypatch.setattr(workers, "handle_wake_direct", handle_wake_direct) + monkeypatch.setattr(clock_module, "allowance_window", lambda root, now=None: dict(AVAILABLE)) + monkeypatch.setattr(BackgroundConsciousness, "_running_roots", staticmethod(lambda: 1)) + get_direct_activity_registry().clear() + clock = BackgroundConsciousness(tmp_path, tmp_path / "repo", lambda: store.get("owner_chat_id"), now=T0) + yield SimpleNamespace(clock=clock, store=store, launches=launches, receipt=receipt, root=tmp_path) + get_direct_activity_registry().clear() -class TestBackgroundContext(unittest.TestCase): - def test_build_context_includes_improvement_backlog_digest(self): - from ouroboros.consciousness import BackgroundConsciousness - - tmpdir = pathlib.Path(tempfile.mkdtemp()) - drive_root = tmpdir / "drive" - repo_dir = tmpdir / "repo" - (repo_dir / "prompts").mkdir(parents=True, exist_ok=True) - (repo_dir / "docs").mkdir(parents=True, exist_ok=True) - (drive_root / "memory" / "knowledge").mkdir(parents=True, exist_ok=True) - (drive_root / "logs").mkdir(parents=True, exist_ok=True) - (drive_root / "state").mkdir(parents=True, exist_ok=True) - - (repo_dir / "prompts" / "CONSCIOUSNESS.md").write_text("Consciousness prompt", encoding="utf-8") - (repo_dir / "BIBLE.md").write_text("Bible", encoding="utf-8") - (repo_dir / "VERSION").write_text("1.2.3", encoding="utf-8") - (repo_dir / "pyproject.toml").write_text('version = "1.2.3"', encoding="utf-8") - (repo_dir / "README.md").write_text("README", encoding="utf-8") - (repo_dir / "docs" / "ARCHITECTURE.md").write_text('# Ouroboros v1.2.3', encoding="utf-8") - (repo_dir / "docs" / "DEVELOPMENT.md").write_text('# Dev', encoding="utf-8") - (drive_root / "state" / "state.json").write_text('{"spent_usd": 0}', encoding="utf-8") - (drive_root / "memory" / "identity.md").write_text("I am Ouroboros", encoding="utf-8") - (drive_root / "memory" / "scratchpad.md").write_text("scratchpad", encoding="utf-8") - (drive_root / "memory" / "knowledge" / "improvement-backlog.md").write_text( - "# Improvement Backlog\n\n### ibl-1\n- status: open\n- created_at: 2026-04-14T09:00:00+00:00\n- source: execution_reflection\n- category: process\n- task_id: task-1\n- requires_plan_review: yes\n- fingerprint: fp-1\n- summary: Reduce recurring task friction around REVIEW_BLOCKED\n", - encoding="utf-8", - ) - for name in ("chat.jsonl", "progress.jsonl", "tools.jsonl", "events.jsonl", "supervisor.jsonl", "task_reflections.jsonl"): - (drive_root / "logs" / name).write_text("", encoding="utf-8") - - with patch.object(BackgroundConsciousness, '_build_registry', return_value=MagicMock()): - bc = BackgroundConsciousness( - drive_root=drive_root, - repo_dir=repo_dir, - event_queue=None, - owner_chat_id_fn=lambda: None, - ) - text = bc._build_context() - self.assertIn("## Improvement Backlog", text) - self.assertIn("Reduce recurring task friction around REVIEW_BLOCKED", text) +def _events(root): + path = root / "logs" / "events.jsonl" + return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] if path.exists() else [] -class TestBackgroundConsciousnessToolScope(unittest.TestCase): - def test_background_consciousness_cannot_execute_or_delegate(self): - from ouroboros.consciousness import BackgroundConsciousness - - tmpdir = pathlib.Path(tempfile.mkdtemp()) - drive_root = tmpdir / "drive" - repo_dir = tmpdir / "repo" - (drive_root / "logs").mkdir(parents=True, exist_ok=True) - repo_dir.mkdir(parents=True, exist_ok=True) - eq = queue.Queue() - - bc = BackgroundConsciousness( - drive_root=drive_root, - repo_dir=repo_dir, - event_queue=eq, - owner_chat_id_fn=lambda: 42, - ) - - schema_names = {s.get("function", {}).get("name") for s in bc._tool_schemas()} - self.assertIn("send_user_message", schema_names) - self.assertIn("update_identity", schema_names) - self.assertIn("recent_tasks", schema_names) - self.assertNotIn("schedule_subagent", schema_names) - self.assertNotIn("get_task_result", schema_names) - self.assertNotIn("wait_task", schema_names) - self.assertNotIn("wait_tasks", schema_names) - self.assertNotIn("run_command", schema_names) - self.assertNotIn("commit_reviewed", schema_names) - - def test_set_next_wakeup_schema_follows_configured_bounds(self): - """The advertised range is the LIVE clamp, not a constant: with - OUROBOROS_BG_WAKEUP_MIN/MAX overridden the schema must say so, because - the handler clamps to those values (prompt-audit review finding).""" - import os - from unittest import mock - - from ouroboros.consciousness import BackgroundConsciousness - - tmpdir = pathlib.Path(tempfile.mkdtemp()) - drive_root = tmpdir / "drive" - repo_dir = tmpdir / "repo" - (drive_root / "logs").mkdir(parents=True, exist_ok=True) - repo_dir.mkdir(parents=True, exist_ok=True) - with mock.patch.dict(os.environ, {"OUROBOROS_BG_WAKEUP_MIN": "60", "OUROBOROS_BG_WAKEUP_MAX": "3600"}): - bc = BackgroundConsciousness( - drive_root=drive_root, - repo_dir=repo_dir, - event_queue=queue.Queue(), - owner_chat_id_fn=lambda: 42, - ) - schema = next( - s["function"] for s in bc._tool_schemas() if s.get("function", {}).get("name") == "set_next_wakeup" - ) - self.assertIn("60-3600", schema["description"]) - self.assertIn("60-3600", schema["parameters"]["properties"]["seconds"]["description"]) - self.assertEqual(bc._wakeup_min, 60) - self.assertEqual(bc._wakeup_max, 3600) +def _register(task_id, *, initiator=""): + actor = SimpleNamespace(_busy=True, _current_task_id=task_id, _accepting_owner_messages=True, + _current_task_metadata={"initiator": initiator} if initiator else {}, + _current_chat_id=7, _current_task_text="x", _task_started_ts=T0) + return get_direct_activity_registry().register(task_id, 7, actor=actor) -class TestBackgroundConsciousnessCost(unittest.TestCase): - def test_think_accepts_unlimited_total_budget(self): - from ouroboros.consciousness import BackgroundConsciousness - from ouroboros.settings_setup_contract import resolve_total_budget_usd - from ouroboros.usage_accounting import current_usage_scope - - bc = object.__new__(BackgroundConsciousness) - bc._drive_root = pathlib.Path(tempfile.mkdtemp()) - bc._bg_budget_pct = 5.0 - captured = [] - bc._think_scoped = lambda: captured.append(current_usage_scope()) or True - - with patch.dict(os.environ, {"TOTAL_BUDGET": "0"}): - self.assertIsNone(resolve_total_budget_usd()) - self.assertTrue(bc._think()) - - self.assertIsNone(captured[0].global_limit_usd) - self.assertIsNone(captured[0].root_limit_usd) - - def test_unknown_round_cost_stays_nullable_in_durable_thought(self): - from ouroboros.consciousness import BackgroundConsciousness - - tmpdir = pathlib.Path(tempfile.mkdtemp()) - drive_root = tmpdir / "drive" - repo_dir = tmpdir / "repo" - (drive_root / "logs").mkdir(parents=True) - repo_dir.mkdir() - - with patch.object(BackgroundConsciousness, "_build_registry", return_value=MagicMock()): - bc = BackgroundConsciousness( - drive_root=drive_root, - repo_dir=repo_dir, - event_queue=None, - owner_chat_id_fn=lambda: None, - ) - - with ( - patch.object(bc, "_build_context", return_value="context"), - patch.object(bc, "_tool_schemas", return_value=[]), - patch.object(bc, "_check_budget", return_value=True), - patch( - "ouroboros.llm_observability.chat_observed", - return_value=({"content": "thought"}, {"cost": None}), - ), - ): - self.assertTrue(bc._think_scoped()) - - events = [ - json.loads(line) - for line in (drive_root / "logs" / "events.jsonl").read_text().splitlines() - ] - thought = next(event for event in events if event.get("type") == "consciousness_thought") - self.assertIsNone(thought["cost_usd"]) - self.assertFalse(thought["cost_final"]) +# --- boot ----------------------------------------------------------------------- +def test_boot_floor_never_wakes_in_the_first_second(clock): + """An overdue persisted next_wake_at is pushed past now + floor; a later one is kept.""" + assert clock.clock.next_wake_at == T0 + FLOOR + clock.store[NEXT_WAKE_STATE_KEY] = T0 + 5000 + later = BackgroundConsciousness(clock.root, clock.root / "repo", lambda: 7, now=T0) + assert later.next_wake_at == T0 + 5000 + assert later.enabled is True -class TestWakeScopedTransportState(unittest.TestCase): - """One Background wake is one cache identity and one active transport turn.""" - def _make(self): - from ouroboros.consciousness import BackgroundConsciousness +def test_legacy_inbox_is_archived_once_without_being_read(clock): + inbox = clock.root / LEGACY_INBOX_REL + inbox.parent.mkdir(parents=True, exist_ok=True) + inbox.write_bytes(b'{"op":"enqueue"\n not json at all\n') + BackgroundConsciousness(clock.root, clock.root / "repo", lambda: 7, now=T0) + assert not inbox.exists() + assert (clock.root / ARCHIVED_INBOX_REL).read_bytes() == b'{"op":"enqueue"\n not json at all\n' + # A second legacy file (an older archive already there) gets its own name, nothing is overwritten. + inbox.write_bytes(b"second") + BackgroundConsciousness(clock.root, clock.root / "repo", lambda: 7, now=T0 + 1) + assert (clock.root / ARCHIVED_INBOX_REL).read_bytes().startswith(b'{"op":"enqueue"') + assert (clock.root / "archive" / f"consciousness_observations_{int(T0 + 1)}.jsonl").read_bytes() == b"second" - tmpdir = pathlib.Path(tempfile.mkdtemp()) - drive_root, repo_dir = tmpdir / "drive", tmpdir / "repo" - (drive_root / "logs").mkdir(parents=True) - repo_dir.mkdir() - with patch.object(BackgroundConsciousness, "_build_registry", return_value=MagicMock()): - bc = BackgroundConsciousness(drive_root=drive_root, repo_dir=repo_dir, - event_queue=None, owner_chat_id_fn=lambda: None) - bc._running = True - return bc - def test_each_wake_owns_one_slot_and_key_and_clears_both(self): - bc = self._make() - seen = [] +# --- tick branches ---------------------------------------------------------------- - def scoped(): - seen.append((bc._model_turn_state, bc._wake_cache_affinity, bc._model_wait.owner_id)) - return True - with patch.object(bc, "_think_scoped", side_effect=scoped): - self.assertTrue(bc._think()) - self.assertTrue(bc._think()) - first, second = seen - # The cache identity IS the wake's existing model-wait owner id. - self.assertEqual(first[1], first[2]) - self.assertEqual(second[1], second[2]) - self.assertIsNone(first[0].envelope) - self.assertIsNot(first[0], second[0]) - self.assertNotEqual(first[1], second[1]) - self.assertIsNone(bc._model_turn_state) - self.assertEqual(bc._wake_cache_affinity, "") +def test_disabled_clock_does_nothing(clock): + clock.clock.stop() + assert clock.clock.tick(T0 + FLOOR + 1) == "disabled" + assert clock.launches == [] - def test_a_wake_round_sends_its_own_key_and_slot(self): - bc = self._make() - sent = [] - def observed(_llm, **kwargs): - sent.append((kwargs, bc._wake_cache_affinity, bc._model_turn_state, bc._model_wait.owner_id)) - return {"content": "thought"}, {"cost": None} +def test_not_due_before_next_wake_at(clock): + assert clock.clock.tick(T0 + FLOOR - 1) == "not_due" + assert clock.launches == [] - with ( - patch.object(bc, "_build_context", return_value="context"), - patch.object(bc, "_tool_schemas", return_value=[]), - patch.object(bc, "_check_budget", return_value=True), - patch("ouroboros.llm_observability.chat_observed", side_effect=observed), - ): - self.assertTrue(bc._think()) - self.assertEqual(len(sent), 1) - kwargs, key, slot, owner_id = sent[0] - self.assertEqual(kwargs["cache_affinity"], owner_id) - self.assertEqual(key, owner_id) - self.assertIs(kwargs["model_turn_state"], slot) -if __name__ == "__main__": - unittest.main() +def test_a_live_wake_or_owner_turn_defers_the_wake(clock): + _register("owner001") + assert clock.clock.tick(T0 + FLOOR + 1) == "owner_turn_live" + get_direct_activity_registry().clear() + _register("wakeXYZ", initiator="consciousness") + assert clock.clock.tick(T0 + FLOOR + 1) == "wake_live" + assert clock.clock.status_snapshot()["live_wake_task_id"] == "wakeXYZ" + assert clock.launches == [] + + +def test_allowance_unknown_skips_with_a_typed_status_and_the_floor(clock, monkeypatch): + monkeypatch.setattr(clock_module, "allowance_window", + lambda root, now=None: {"status": "allowance_unknown", "error": "OSError: ledger"}) + now = T0 + FLOOR + 1 + assert clock.clock.tick(now) == "skipped:allowance_unknown" + assert clock.clock.next_wake_at == now + FLOOR + assert clock.store[NEXT_WAKE_STATE_KEY] == now + FLOOR + snapshot = clock.clock.status_snapshot() + assert snapshot["last_wake_outcome"] == "skipped:allowance_unknown" and "OSError" in snapshot["last_error"] + assert [row["reason"] for row in _events(clock.root) if row["type"] == "consciousness_wake_skipped"] == ["allowance_unknown"] + assert clock.launches == [] + + +def test_allowance_exhausted_skips_until_the_window_frees(clock, monkeypatch): + from ouroboros.deadline_utils import parse_deadline_ts + + resets_at = "2027-02-01T00:00:00+00:00" + monkeypatch.setattr(clock_module, "allowance_window", lambda root, now=None: { + **AVAILABLE, "status": "exhausted", "accounted_usd": 21.0, "remaining_usd": 0.0, "resets_at": resets_at}) + assert clock.clock.tick(T0 + FLOOR + 1) == "skipped:allowance_exhausted" + assert clock.clock.next_wake_at == parse_deadline_ts(resets_at).timestamp() + assert clock.clock.status_snapshot()["last_wake_outcome"] == "skipped:allowance_exhausted" + # A reset instant already in the past (or none: DAILY_USD=0) still waits at least the floor. + monkeypatch.setattr(clock_module, "allowance_window", lambda root, now=None: { + **AVAILABLE, "status": "exhausted", "resets_at": ""}) + clock.clock._next_wake_at = T0 + assert clock.clock.tick(T0 + 5) == "skipped:allowance_exhausted" + assert clock.clock.next_wake_at == T0 + 5 + FLOOR + + +def test_no_owner_chat_waits_for_the_first_conversation(clock): + clock.store["owner_chat_id"] = None + assert clock.clock.tick(T0 + FLOOR + 1) == "skipped:waiting_for_first_conversation" + assert clock.clock.next_wake_at == T0 + FLOOR + 1 + FLOOR + assert clock.launches == [] + + +# --- launch ----------------------------------------------------------------------- + + +def test_launch_starts_an_ordinary_main_turn_with_the_wake_envelope(clock): + clock.store[INTERVAL_STATE_KEY] = 1200 + now = T0 + FLOOR + 1 + assert clock.clock.tick(now) == "launched" + [launch] = clock.launches + assert launch["chat_id"] == 7 + meta = launch["metadata"] + assert meta["initiator"] == "consciousness" and meta["usage_category"] == "consciousness" + assert meta["wake_reason"] == "heartbeat" and meta["consciousness_autonomy"] == "act" + assert meta["model_role"] == "consciousness" and meta["runtime_mode_cap"] == "light" + assert "toggle_evolution" in meta["disabled_tools"] and "steer_task" not in meta["disabled_tools"] + assert meta["root_cost_ceiling_usd"] == 17.5 # min(per-task cap 50, remaining 17.5) + text = launch["text"] + assert text.startswith("[Wake-up · heartbeat]") and "level=act" in text and "spent=2.50/20.00" in text + assert "running=1/2" in text and "interval=1200" in text and "toggle_evolution" in text + assert "no wake since this process started" in text + assert launch["on_finished"] == clock.clock._wake_finished + snapshot = clock.clock.status_snapshot() + assert snapshot["last_wake_task_id"] == "wake0001" and snapshot["last_wake_outcome"] == "running" + started = [row for row in _events(clock.root) if row["type"] == "consciousness_wake_started"] + assert started and started[0]["task_id"] == "wake0001" and started[0]["wake_reason"] == "heartbeat" + + +def test_launch_ceiling_is_the_remaining_allowance_when_no_per_task_cap(clock, monkeypatch): + monkeypatch.setenv("OUROBOROS_PER_TASK_COST_USD", "0") + assert clock.clock.tick(T0 + FLOOR + 1) == "launched" + assert clock.launches[0]["metadata"]["root_cost_ceiling_usd"] == 17.5 + + +def test_pending_reason_is_captured_and_cleared_at_launch(clock): + clock.clock.notify("task_finished:abc:completed") + assert clock.clock.tick(T0 + FLOOR + 1) == "launched" + assert clock.launches[0]["metadata"]["wake_reason"] == "task_finished:abc:completed" + assert clock.launches[0]["text"].startswith("[Wake-up · task_finished:abc:completed]") + assert clock.clock.pending_reason is None + + +def test_rejected_wake_is_typed_and_retried_after_the_floor(clock): + clock.receipt.update({"admitted": False, "task_id": "", "reason": "budget_exhausted"}) + now = T0 + FLOOR + 1 + assert clock.clock.tick(now) == "rejected:budget_exhausted" + assert clock.clock.next_wake_at == now + FLOOR + assert clock.clock.status_snapshot()["last_wake_outcome"] == "rejected:budget_exhausted" + assert [row["reason"] for row in _events(clock.root) if row["type"] == "consciousness_wake_rejected"] == ["budget_exhausted"] + + +def test_a_turn_admitted_in_the_same_instant_keeps_the_reason_for_later(clock, monkeypatch): + from supervisor import workers + + def handle_wake_direct(*_a, **_k): + raise AssertionError("must not launch beside a live turn") + + monkeypatch.setattr(workers, "handle_wake_direct", handle_wake_direct) + calls = {"n": 0} + real = clock.clock.live_turns + + def live_turns(): + calls["n"] += 1 + if calls["n"] == 2: # the re-check under the gate lock sees a just-admitted owner turn + _register("owner002") + return real() + + monkeypatch.setattr(clock.clock, "live_turns", live_turns) + clock.clock.notify("project_digest:p1") + assert clock.clock.tick(T0 + FLOOR + 1) == "owner_turn_live" + assert clock.clock.pending_reason == "project_digest:p1" + + +# --- finish ----------------------------------------------------------------------- + + +def _launched(clock, now=T0 + FLOOR + 1): + assert clock.clock.tick(now) == "launched" + return clock.launches[-1]["on_finished"] + + +def test_finish_schedules_the_chosen_interval_clamped(clock, monkeypatch): + finished = _launched(clock) + monkeypatch.setattr(clock_module.time, "time", lambda: T0 + 5000) + clock.store[INTERVAL_STATE_KEY] = 100 # below the floor + finished("wake0001", True) + assert clock.clock.next_wake_at == T0 + 5000 + FLOOR + snapshot = clock.clock.status_snapshot() + assert snapshot["last_wake_outcome"] == "done" and snapshot["last_error"] == "" + assert snapshot["last_wake_at"].startswith("2027-") + clock.store[INTERVAL_STATE_KEY] = 10 ** 6 # above the ceiling + finished("wake0001", True) + assert clock.clock.next_wake_at == T0 + 5000 + CEILING + del clock.store[INTERVAL_STATE_KEY] # no choice → the default + finished("wake0001", True) + assert clock.clock.next_wake_at == T0 + 5000 + DEFAULT + + +def test_finish_with_a_pending_reason_wakes_after_the_floor(clock, monkeypatch): + finished = _launched(clock) + monkeypatch.setattr(clock_module.time, "time", lambda: T0 + 5000) + clock.clock.notify("task_finished:xyz:failed") + finished("wake0001", True) + assert clock.clock.next_wake_at == T0 + 5000 + FLOOR + assert clock.clock.pending_reason == "task_finished:xyz:failed" + + +def test_runner_failure_backs_off_by_doubling_until_a_wake_succeeds(clock, monkeypatch): + finished = _launched(clock) + monkeypatch.setattr(clock_module.time, "time", lambda: T0 + 5000) + finished("wake0001", False) + assert clock.clock.next_wake_at == T0 + 5000 + DEFAULT * 2 + snapshot = clock.clock.status_snapshot() + assert snapshot["last_wake_outcome"] == "failed" and "wake0001" in snapshot["last_error"] + finished("wake0001", False) + assert clock.clock.next_wake_at == T0 + 5000 + DEFAULT * 4 + finished("wake0001", False) + assert clock.clock.next_wake_at == T0 + 5000 + CEILING # 3300 * 8 > 14400 + finished("wake0001", True) + assert clock.clock.next_wake_at == T0 + 5000 + DEFAULT + + +# --- notify ----------------------------------------------------------------------- + + +def test_notify_pulls_the_next_wake_to_the_floor_after_the_last_wake(clock, monkeypatch): + clock.clock._next_wake_at = T0 + 3000 + monkeypatch.setattr(clock_module.time, "time", lambda: T0 + 100) + clock.clock.notify("task_finished:a:completed") + assert clock.clock.next_wake_at == T0 + 100 # no wake yet: now + clock.clock._last_wake_at = T0 + clock.clock._next_wake_at = T0 + 3000 + clock.clock.notify("task_finished:b:completed") + assert clock.clock.next_wake_at == T0 + FLOOR # arithmetic debounce off the last wake + clock.clock.notify("task_finished:c:completed") + assert clock.clock.next_wake_at == T0 + FLOOR and clock.clock.pending_reason == "task_finished:c:completed" + # A notify never pushes a nearer wake further away. + clock.clock._next_wake_at = T0 + 50 + clock.clock.notify("task_finished:d:completed") + assert clock.clock.next_wake_at == T0 + 50 + + +def test_task_done_notifies_for_roots_of_any_outcome_but_never_for_consciousness_origin(): + from supervisor.events_task_done import _notify_consciousness_of_root_done + + reasons: list = [] + ctx = SimpleNamespace(consciousness=SimpleNamespace(notify=reasons.append)) + _notify_consciousness_of_root_done(ctx, {}, None, {"status": "failed"}, {"task_id": "t1", "status": "failed"}) + _notify_consciousness_of_root_done(ctx, {"delegation_role": "root"}, None, {}, {"task_id": "t2", "status": "completed"}) + _notify_consciousness_of_root_done(ctx, {"delegation_role": "subagent"}, None, {}, {"task_id": "t3", "status": "completed"}) + _notify_consciousness_of_root_done(ctx, {}, None, {"metadata": {"initiator": "consciousness"}}, + {"task_id": "wake1", "status": "completed"}) + _notify_consciousness_of_root_done(ctx, {}, {"initiator": "consciousness", "usage_category": "consciousness_task"}, {}, + {"task_id": "started1", "status": "completed"}) + assert reasons == ["task_finished:t1:failed", "task_finished:t2:completed"] + # No alarm clock on the ctx (supervisor init failed) is not an error. + _notify_consciousness_of_root_done(SimpleNamespace(), {}, None, {}, {"task_id": "t4", "status": "completed"}) + + +def test_project_digest_and_orphan_heal_reach_notify(monkeypatch, tmp_path): + from ouroboros import server_maintenance + from supervisor.events_project_routing import _handle_project_digest + + reasons: list = [] + ctx = SimpleNamespace(DRIVE_ROOT=tmp_path, consciousness=SimpleNamespace(notify=reasons.append)) + monkeypatch.setattr("ouroboros.projects_registry.touch_project", lambda root, pid: None) + _handle_project_digest({"project_id": "p9", "task_id": "t9"}, ctx) + assert reasons == ["project_digest:p9"] + monkeypatch.setattr("ouroboros.skill_review_runner.reconcile_stale_review_jobs", lambda root: None) + monkeypatch.setattr("ouroboros.task_status.reconcile_orphaned_running_tasks", lambda root, **kw: 2) + monkeypatch.setattr("ouroboros.projects_registry.reconcile_projects", lambda root: None) + monkeypatch.setattr(server_maintenance, "_resume_interrupted_project_deletions", lambda: None) + healed: list = [] + server_maintenance._periodic_zombie_reconcile(on_orphans_healed=healed.append) + assert healed == [2] + + +# --- owner controls --------------------------------------------------------------- + + +def test_start_and_stop_flip_the_flag_and_stop_arms_a_graceful_stop_of_a_live_wake(clock, monkeypatch): + import threading + + assert clock.clock.start() == "Background consciousness is already enabled." + assert clock.clock.stop() == "Background consciousness disabled." + assert clock.clock.enabled is False and clock.clock.tick(T0 + FLOOR + 1) == "disabled" + assert clock.clock.stop() == "Background consciousness is already disabled." + assert clock.clock.start().startswith("Background consciousness enabled; next wake-up at ") + assert clock.clock.enabled is True + _launched(clock) + _register("wake0001", initiator="consciousness") + stopped: list = [] + done = threading.Event() + + def stop_direct_chat_turn(task_id, turn, **_kw): + stopped.append((task_id, turn["id"])) + done.set() + return "ended" + + monkeypatch.setattr("supervisor.worker_chat_lane.stop_direct_chat_turn", stop_direct_chat_turn) + message = clock.clock.stop() + assert message == "Background consciousness disabled; wake-up wake0001 ends at its next step." + assert done.wait(5) and stopped == [("wake0001", "wake0001")] + + +def test_status_snapshot_carries_the_alarm_facts(clock): + snapshot = clock.clock.status_snapshot() + assert set(snapshot) == { + "enabled", "level", "next_wake_at", "pending_reason", "last_wake_at", "last_wake_task_id", + "last_wake_outcome", "last_error", "spent_24h_usd", "daily_usd", "allowance_resets_at", + "tasks_running", "max_tasks", "live_wake_task_id", + } + assert snapshot["enabled"] is True and snapshot["level"] == "act" + assert snapshot["next_wake_at"].startswith("2027-") and snapshot["last_wake_at"] == "" + assert snapshot["spent_24h_usd"] == 2.5 and snapshot["daily_usd"] == 20.0 + assert snapshot["tasks_running"] == 1 and snapshot["max_tasks"] == 2 and snapshot["live_wake_task_id"] == "" diff --git a/tests/test_consciousness_wake.py b/tests/test_consciousness_wake.py new file mode 100644 index 000000000..df272b79b --- /dev/null +++ b/tests/test_consciousness_wake.py @@ -0,0 +1,168 @@ +"""The wake-up message and envelope (Background Consciousness redesign P2), and the +server's projection of the alarm clock's snapshot into one honest status line.""" + +from __future__ import annotations + +import json +import pathlib +from types import SimpleNamespace + +import pytest + +from ouroboros import consciousness_wake as wake + +REPO = pathlib.Path(__file__).resolve().parents[1] +T0 = 1_800_000_000.0 + + +def _iso(ts): + return wake._iso(ts) + + +def _result(root, task_id, *, status="completed", ts, cost=1.25, direct=False, quizzes=None, description=""): + (root / "task_results").mkdir(exist_ok=True) + row = {"task_id": task_id, "status": status, "ts": _iso(ts), "updated_at": _iso(ts), "_schema_version": 1, + "accounted_upper_bound_usd": cost, "description": description or f"do {task_id}", + "metadata": {}, "_is_direct_chat": direct} + if quizzes: + row["owner_quiz"] = quizzes + (root / "task_results" / f"{task_id}.json").write_text(json.dumps(row), encoding="utf-8") + + +def test_wake_task_metadata_carries_origin_level_and_soft_ceiling(monkeypatch): + monkeypatch.setenv("OUROBOROS_CONSCIOUSNESS_AUTONOMY", "act") + meta = wake.wake_task_metadata("observe", "heartbeat", root_cost_ceiling_usd=3.5) + assert meta["initiator"] == "consciousness" and meta["usage_category"] == "consciousness" + assert meta["consciousness_autonomy"] == "observe" and meta["runtime_mode_cap"] == "light" + assert meta["model_role"] == "consciousness" and meta["wake_reason"] == "heartbeat" + assert "promote_chat_to_task" in meta["disabled_tools"] and meta["root_cost_ceiling_usd"] == 3.5 + full = wake.wake_task_metadata("full", "event:x") + assert full["disabled_tools"] == [] and full["runtime_mode_cap"] == "" and "root_cost_ceiling_usd" not in full + assert wake.wake_task_metadata("bogus", "")["consciousness_autonomy"] == "act" # falls back to the setting + + +def test_events_list_settled_tasks_open_cards_and_owner_messages_since_the_last_wake(tmp_path): + since = T0 - 3600 + _result(tmp_path, "old01", ts=since - 10) + _result(tmp_path, "new01", ts=since + 10, status="failed", cost=0.5, description="build the thing") + _result(tmp_path, "run01", ts=since + 20, status="running") + _result(tmp_path, "chat1", ts=since + 30, direct=True) # an owner's own turn: already in Recent chat + _result(tmp_path, "prev1", ts=since + 40) # the previous wake itself is excluded by id + _result(tmp_path, "ask01", ts=since - 100, status="running", + quizzes={"q1": {"state": "open"}, "q2": {"state": "answered"}}) + (tmp_path / "logs").mkdir() + (tmp_path / "logs" / "chat.jsonl").write_text("\n".join([ + json.dumps({"direction": "in", "ts": _iso(since + 5), "text": "hi"}), + json.dumps({"direction": "out", "ts": _iso(since + 6), "text": "hello"}), + json.dumps({"direction": "in", "ts": _iso(since - 5), "text": "earlier"}), + json.dumps({"direction": "in", "ts": _iso(since + 7), "text": "again"}), + ]) + "\n", encoding="utf-8") + lines = wake.wake_events(tmp_path, since=since, now=T0, exclude_task_id="prev1") + assert "- open question card q1 on task ask01 (no answer yet)" in lines + assert "- task new01 failed, $0.50: build the thing" in lines + assert "- 2 message(s) from your human (see Recent chat)" in lines + assert not [line for line in lines if "old01" in line or "run01" in line or "chat1" in line or "prev1" in line] + assert wake.wake_events(tmp_path / "missing", since=since, now=T0) == [] + + +def test_render_substitutes_every_placeholder_and_truncates_events_honestly(tmp_path, monkeypatch): + repo = tmp_path / "repo" + (repo / "prompts").mkdir(parents=True) + (repo / "prompts" / "CONSCIOUSNESS.md").write_text( + (REPO / "prompts" / "CONSCIOUSNESS.md").read_text(encoding="utf-8"), encoding="utf-8") + for index in range(15): + _result(tmp_path, f"t{index:02d}", ts=T0 - 100 + index) + text = wake.render_wake_message( + tmp_path, repo, reason="task_finished:t14:completed", last_wake_at=T0 - 5400, since=T0 - 5400, now=T0, + level="act", disabled_tools=["toggle_evolution", "request_restart"], spent_usd=4.0, daily_usd=20.0, + running=1, max_tasks=2, interval=3300) + for key in wake.PLACEHOLDERS: + assert "{" + key + "}" not in text, key + assert text.startswith("[Wake-up · task_finished:t14:completed]") + assert "1 h 30 min ago" in text and "autonomy act — everything your runtime mode allows except" in text + assert "toggle_evolution, request_restart (calling them is refused)" in text + assert "Allowance (last 24 h): 4.00 / 20.00 USD" in text and "still running: 1/2" in text + assert "wake-up interval is 3300 s" in text + assert text.count("- task t") == wake.EVENT_LINES_MAX and "(+5 more; see recent_tasks)" in text + quiet = wake.render_wake_message( + tmp_path, repo, reason="heartbeat", last_wake_at=0.0, since=T0 + 1, now=T0, level="full", + disabled_tools=[], spent_usd=None, daily_usd=0, running=0, max_tasks=0, interval=900) + assert "no wake since this process started" in quiet and "nothing new" in quiet + assert "withheld at this level: none" in quiet and "Allowance (last 24 h): unknown / 0.00 USD" in quiet + assert "including evolution" in quiet + + +def test_render_survives_a_missing_template_and_an_unreadable_task_store(tmp_path): + (tmp_path / "task_results").mkdir() + (tmp_path / "task_results" / "broken.json").write_text("{not json", encoding="utf-8") + text = wake.render_wake_message( + tmp_path, tmp_path / "no-repo", reason="heartbeat", last_wake_at=0.0, since=T0 - 1, now=T0, level="act", + disabled_tools=[], spent_usd=0.0, daily_usd=20.0, running=0, max_tasks=2, interval=3300) + assert text.startswith("[Wake-up · heartbeat]") and "{" not in text + + +def test_template_names_only_its_placeholders_and_the_wake_hints(): + template = (REPO / "prompts" / "CONSCIOUSNESS.md").read_text(encoding="utf-8") + import re + + assert set(re.findall(r"\{([a-z_]+)\}", template)) == set(wake.PLACEHOLDERS) + for hint in ("Doing nothing is a fine outcome", "ask only when the answer changes what you do", + "say what you assume meanwhile", "choose how long", "do not request an acceptance review", + "One maintenance item per wake is a good rhythm", "`set_next_wakeup`", "Allowance (last 24 h)", + "be brief, no essays unless something matters", "people you talk with"): + assert hint in template, hint + assert "up to 10 rounds" not in template and "300 seconds" not in template + + +# --- the server projection -------------------------------------------------------- + + +@pytest.fixture +def describe(monkeypatch): + import server + + holder = {} + monkeypatch.setattr(server, "_consciousness", SimpleNamespace(status_snapshot=lambda: dict(holder))) + return lambda snapshot, enabled=True: (holder.clear(), holder.update(snapshot), server._describe_bg_consciousness_state(enabled))[2] + + +BASE = {"enabled": True, "level": "act", "next_wake_at": "2027-01-15T12:30:00+00:00", "pending_reason": "", + "last_wake_at": "", "last_wake_task_id": "", "last_wake_outcome": "", "last_error": "", + "spent_24h_usd": 1.0, "daily_usd": 20.0, "allowance_resets_at": "", "tasks_running": 0, "max_tasks": 2, + "live_wake_task_id": ""} + + +def test_projection_names_every_honest_status(describe): + import server + + assert describe(BASE, enabled=False)["status"] == "disabled" + sleeping = describe(BASE) + assert sleeping["status"] == "sleeping" and sleeping["detail"] == f"Sleeping until {server._clock_of(BASE['next_wake_at'])}." + assert sleeping["next_wake_at"] == BASE["next_wake_at"] and sleeping["enabled"] is True + pending = describe({**BASE, "pending_reason": "task_finished:a:completed"}) + assert pending["detail"].endswith(" Early wake pending: task_finished:a:completed.") + thinking = describe({**BASE, "live_wake_task_id": "wake0001"}) + assert thinking["status"] == "thinking" and "wake0001" in thinking["detail"] + first = describe({**BASE, "last_wake_outcome": "skipped:waiting_for_first_conversation"}) + assert first["status"] == "waiting_for_first_conversation" + exhausted = describe({**BASE, "last_wake_outcome": "skipped:allowance_exhausted", "spent_24h_usd": 21.5, + "allowance_resets_at": "2027-01-15T18:00:00+00:00"}) + assert exhausted["status"] == "allowance_exhausted" + assert "$21.50 of $20.00" in exhausted["detail"] and server._clock_of("2027-01-15T18:00:00+00:00") in exhausted["detail"] + unknown = describe({**BASE, "last_wake_outcome": "skipped:allowance_unknown", "last_error": "OSError: ledger"}) + assert unknown["status"] == "allowance_unknown" and "OSError: ledger" in unknown["detail"] + rejected = describe({**BASE, "last_wake_outcome": "rejected:budget_exhausted"}) + assert rejected["status"] == "wake_rejected" and "budget_exhausted" in rejected["detail"] + failed = describe({**BASE, "last_wake_outcome": "failed", "last_error": "wake-up w1 failed in its runner"}) + assert failed["status"] == "wake_failed" and "backing off" in failed["detail"] + done = describe({**BASE, "last_wake_outcome": "done", "last_wake_at": "2027-01-15T11:35:00+00:00"}) + assert done["status"] == "sleeping" + + +def test_projection_without_a_constructed_clock_is_stopped_not_running(monkeypatch): + import server + + monkeypatch.setattr(server, "_consciousness", None) + described = server._describe_bg_consciousness_state(True) + assert described["status"] == "stopped" and "not constructed" in described["detail"] + assert server._describe_bg_consciousness_state(False)["status"] == "disabled" diff --git a/tests/test_context.py b/tests/test_context.py index de336ea59..5e0db20a8 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -492,8 +492,10 @@ def test_health_invariants_come_first_in_dynamic_context(tmp_path): assert dynamic_text.index("## Health Invariants") < dynamic_text.index("## Drive state") -def test_health_invariants_come_first_in_background_consciousness_context(tmp_path): - from ouroboros.consciousness import BackgroundConsciousness +def test_health_invariants_come_first_in_a_consciousness_wake_context(tmp_path): + """A wake-up is an ordinary Main turn: the same builder, the same section order.""" + from ouroboros.context import build_llm_messages + from ouroboros.memory import Memory repo_dir = tmp_path / "repo" drive_root = tmp_path / "drive" @@ -503,7 +505,7 @@ def test_health_invariants_come_first_in_background_consciousness_context(tmp_pa (drive_root / "logs").mkdir(parents=True, exist_ok=True) (drive_root / "state").mkdir(parents=True, exist_ok=True) - (repo_dir / "prompts" / "CONSCIOUSNESS.md").write_text("Consciousness prompt", encoding="utf-8") + (repo_dir / "prompts" / "SYSTEM.md").write_text("System prompt", encoding="utf-8") (repo_dir / "BIBLE.md").write_text("Bible", encoding="utf-8") (repo_dir / "VERSION").write_text("1.2.3", encoding="utf-8") (repo_dir / "pyproject.toml").write_text('version = "1.2.3"', encoding="utf-8") @@ -523,15 +525,31 @@ def test_health_invariants_come_first_in_background_consciousness_context(tmp_pa (drive_root / "logs" / "supervisor.jsonl").write_text("", encoding="utf-8") (drive_root / "logs" / "task_reflections.jsonl").write_text("", encoding="utf-8") - bg = BackgroundConsciousness( - drive_root=drive_root, - repo_dir=repo_dir, - event_queue=None, - owner_chat_id_fn=lambda: None, + class FakeEnv: + def drive_path(self, p): + return drive_root / p + + def repo_path(self, p): + return repo_dir / p + + @property + def repo_dir(self): + return repo_dir + + @property + def drive_root(self): + return drive_root + + messages, _cap_info = build_llm_messages( + env=FakeEnv(), + memory=Memory(drive_root=drive_root, repo_dir=repo_dir), + task={"id": "wake1", "type": "task", "text": "[Wake-up · heartbeat]", "_is_direct_chat": True, + "metadata": {"initiator": "consciousness", "usage_category": "consciousness"}}, ) - text = bg._build_context() - assert text.index("## Health Invariants") < text.index("## Drive state") + dynamic_text = messages[0]["content"][2]["text"] + assert dynamic_text.startswith("## Health Invariants") + assert dynamic_text.index("## Health Invariants") < dynamic_text.index("## Drive state") def test_project_recent_chat_filters_archives_before_recent_bound(tmp_path, monkeypatch): diff --git a/tests/test_context_budget_ssot.py b/tests/test_context_budget_ssot.py index a2f922291..392b16a7a 100644 --- a/tests/test_context_budget_ssot.py +++ b/tests/test_context_budget_ssot.py @@ -18,9 +18,8 @@ def _src(rel: str) -> str: def test_agent_context_budget_values_pinned(): """Values are the SSOT; changing them is a deliberate, visible edit.""" assert cb.OWNER_LOW_TARGET_TOKENS == 200_000 - assert cb.BG_CONTEXT_WARN_CHARS == 600_000 - assert cb.BG_CONTEXT_MAX_CHARS == 1_200_000 - assert cb.BG_STATE_JSON_WARN_CHARS == 200_000 + for retired in ("BG_CONTEXT_WARN_CHARS", "BG_CONTEXT_MAX_CHARS", "BG_STATE_JSON_WARN_CHARS", "BG_OBSERVATIONS_WARN_BYTES"): + assert not hasattr(cb, retired), retired # a wake-up is a Main turn under Main's budgets assert cb.LARGE_CONTEXT_SECTION_CHARS == 200_000 assert cb.MAX_RECENT_CHAT_TAIL == 1000 assert cb.CHAT_ARCHIVE_SCAN_WARN_BYTES == 100_000_000 @@ -83,10 +82,6 @@ def test_call_sites_import_the_ssot_names(): assert "read_unconsolidated_chat" in ctx_recent_src assert "last_consolidated_offset" in _src("ouroboros/memory.py") - consc_src = _src("ouroboros/consciousness.py") - for name in ("BG_CONTEXT_MAX_CHARS", "BG_CONTEXT_WARN_CHARS", "BG_STATE_JSON_WARN_CHARS"): - assert name in consc_src, f"consciousness.py must consume {name}" - ctx_src = _src("ouroboros/context.py") assert "LARGE_CONTEXT_SECTION_CHARS" in ctx_src assert "CONTEXT_SOFT_CAP_TOKENS" not in ctx_src @@ -99,11 +94,6 @@ def test_old_bare_literals_are_gone_from_call_sites(): """The decisive anti-drift check: no bare literal can outlive the SSOT.""" assert "> 1_200_000" not in _src("ouroboros/loop.py") - consc = _src("ouroboros/consciousness.py") - assert "= 1_200_000" not in consc - assert "= 600_000" not in consc - assert "> 200_000" not in consc - ctx = _src("ouroboros/context.py") assert "= 200_000" not in ctx assert "_soft_cap = 200_000" not in _src("ouroboros/agent.py") diff --git a/tests/test_docs_sync.py b/tests/test_docs_sync.py index f3063c091..9d1752599 100644 --- a/tests/test_docs_sync.py +++ b/tests/test_docs_sync.py @@ -361,14 +361,17 @@ def test_chat_id_addressing_docs_match_the_code_that_routes_it(): assert "tests/test_chat_id_truthiness_guard.py" in development -def test_consciousness_prompt_matches_scope_limited_contracts(): +def test_consciousness_prompt_is_the_wake_message_of_an_ordinary_main_turn(): + """The wake-up runs on Main's system prompt and tools (owner decision В15); this file + is its USER message: no private capability catalog, no round or interval limits.""" consciousness = _read("prompts/CONSCIOUSNESS.md") - assert "schedule subagents" in consciousness - assert "wait on subagents" in consciousness - assert "Update your scratchpad or identity" in consciousness - assert "Write to my human proactively" in consciousness + assert consciousness.startswith("[Wake-up · {reason}]") + assert "Doing nothing is a fine outcome" in consciousness + assert "`set_next_wakeup`" in consciousness and "`escalate`" in consciousness assert "recent_tasks" in consciousness + for retired in ("You can:", "up to 10 rounds", "Default wakeup", "background consciousness mode"): + assert retired not in consciousness, retired def test_phase3_governance_language_is_pinned_without_new_qa_surface(): @@ -577,35 +580,23 @@ def _prompt_bare_identifiers(text: str) -> set: def test_prompt_tool_names_resolve_to_registered_tools(tmp_path): """Every backticked snake_case identifier in the three runtime prompts is - either a registered tool (public schema), a background-consciousness tool, - or a documented non-tool identifier. Completeness is deliberately NOT - required (the schemas are the catalog); this only forbids phantoms and - stale spellings, the drift class the prompt audit found in every prompt.""" - from ouroboros.consciousness import BackgroundConsciousness + either a registered tool (public schema) or a documented non-tool identifier + (for the wake-up template also one of its own render placeholders). + Completeness is deliberately NOT required (the schemas are the catalog); + this only forbids phantoms and stale spellings, the drift class the prompt + audit found in every prompt. The wake-up runs on the full registry, so its + universe is Main's.""" + from ouroboros.consciousness_wake import PLACEHOLDERS root = pathlib.Path(__file__).resolve().parent.parent registry = ToolRegistry(repo_dir=tmp_path / "repo", drive_root=tmp_path / "data") registered = {schema["function"]["name"] for schema in registry.schemas()} - # The background whitelist is not taken on faith: every name in it must be a - # registered public tool or a ToolEntry the consciousness module registers - # itself (set_next_wakeup and friends), otherwise the whitelist has rotted. - consciousness_src = (root / "ouroboros" / "consciousness.py").read_text(encoding="utf-8") - bg_private = set(re.findall(r'ToolEntry\("([a-z0-9_]+)"', consciousness_src)) - stale_whitelist = set(BackgroundConsciousness._BG_TOOL_WHITELIST) - registered - bg_private - assert not stale_whitelist, f"_BG_TOOL_WHITELIST names unregistered tools: {sorted(stale_whitelist)}" - universe = ( - registered - | set(BackgroundConsciousness._BG_TOOL_WHITELIST) - | PROMPT_NON_TOOL_IDENTIFIERS - ) - # CONSCIOUSNESS.md runs on the background registry, which admits ONLY the - # whitelist (consciousness.py _tool_schemas/_execute_tool), so a public tool - # that is not whitelisted is a phantom there. - bg_universe = set(BackgroundConsciousness._BG_TOOL_WHITELIST) | PROMPT_NON_TOOL_IDENTIFIERS + universe = registered | PROMPT_NON_TOOL_IDENTIFIERS + wake_universe = universe | set(PLACEHOLDERS) for rel, allowed in ( ("prompts/SYSTEM.md", universe), ("prompts/SAFETY.md", universe), - ("prompts/CONSCIOUSNESS.md", bg_universe), + ("prompts/CONSCIOUSNESS.md", wake_universe), ): text = (root / rel).read_text(encoding="utf-8") unresolved = _prompt_backticked_identifiers(text) - allowed @@ -613,11 +604,10 @@ def test_prompt_tool_names_resolve_to_registered_tools(tmp_path): f"{rel} names identifiers that are neither registered tools nor " f"classified non-tool identifiers: {sorted(unresolved)}" ) - # CONSCIOUSNESS.md writes tool names without backticks; its bare snake_case - # tokens must resolve the same way (the runtime drift check in - # context_health only catches names with known prefixes). + # The wake template also names tools without backticks; its bare snake_case + # tokens (the render placeholders aside) must resolve the same way. bare = _prompt_bare_identifiers((root / "prompts" / "CONSCIOUSNESS.md").read_text(encoding="utf-8")) - unresolved_bare = bare - bg_universe + unresolved_bare = bare - wake_universe assert not unresolved_bare, ( f"prompts/CONSCIOUSNESS.md names bare identifiers that are neither registered tools " f"nor classified non-tool identifiers: {sorted(unresolved_bare)}" diff --git a/tests/test_durable_learning_completeness.py b/tests/test_durable_learning_completeness.py index e5427f67c..82386e942 100644 --- a/tests/test_durable_learning_completeness.py +++ b/tests/test_durable_learning_completeness.py @@ -375,28 +375,43 @@ def test_closed_objective_unavailable_abstains_before_chooser(tmp_path, monkeypa assert called == [] -def _bg_fixture(tmp_path, *, backlog_count=10): - from ouroboros.consciousness import BackgroundConsciousness - from ouroboros.improvement_backlog import append_backlog_items +def _wake_context(tmp_path, *, chat_rows=None): + """The system text a consciousness wake-up gets: Main's own builder (build_llm_messages) + over a wake-shaped task and the real repository prompts — the typed gap facts the + context builders disclose (recent-chat, dialogue-history and schedule digests) are + what the wake reads; nothing consciousness-specific is layered on top.""" + from ouroboros.context import build_llm_messages + from ouroboros.memory import Memory repo_dir = pathlib.Path(__file__).parents[1] - (tmp_path / "logs").mkdir(parents=True) - (tmp_path / "logs" / "chat.jsonl").write_text( - json.dumps({"chat_id": 1, "direction": "in", "text": "complete recent chat"}) + "\n", - encoding="utf-8", - ) - (tmp_path / "state").mkdir(parents=True) - (tmp_path / "state" / "state.json").write_text("{}", encoding="utf-8") - for idx in range(backlog_count): - append_backlog_items(tmp_path, [{ - "id": f"ibl-bg-{idx}", "fingerprint": f"fp-bg-{idx}", - "summary": f"background item {idx}", "category": "identity", "source": "reflection", - }]) - return BackgroundConsciousness(tmp_path, repo_dir, None, lambda: None) + class FakeEnv: + def drive_path(self, p): + return tmp_path / p -def _tool_call(name, args, call_id): - return {"id": call_id, "function": {"name": name, "arguments": json.dumps(args)}} + def repo_path(self, p): + return repo_dir / p + + @property + def repo_dir(self): + return repo_dir + + @property + def drive_root(self): + return tmp_path + + (tmp_path / "logs").mkdir(parents=True, exist_ok=True) + (tmp_path / "state").mkdir(parents=True, exist_ok=True) + (tmp_path / "memory").mkdir(parents=True, exist_ok=True) + if not (tmp_path / "state" / "state.json").exists(): + (tmp_path / "state" / "state.json").write_text("{}", encoding="utf-8") + rows = chat_rows if chat_rows is not None else [json.dumps({"chat_id": 1, "direction": "in", "text": "complete recent chat"})] + (tmp_path / "logs" / "chat.jsonl").write_text("".join(row + "\n" for row in rows), encoding="utf-8") + task = {"id": "wake1", "type": "task", "text": "[Wake-up · heartbeat]", "_is_direct_chat": True, + "metadata": {"initiator": "consciousness", "usage_category": "consciousness", + "consciousness_autonomy": "act"}} + messages, _cap = build_llm_messages(env=FakeEnv(), memory=Memory(drive_root=tmp_path, repo_dir=repo_dir), task=task) + return "\n\n".join(block["text"] for block in messages[0]["content"]) def _write_schedules(tmp_path, count): @@ -404,311 +419,45 @@ def _write_schedules(tmp_path, count): "id": f"schedule-{idx}", "name": f"schedule {idx}", "enabled": True, "trigger": {"type": "cron", "expr": "0 * * * *"}, } for idx in range(count)] + (tmp_path / "state").mkdir(parents=True, exist_ok=True) (tmp_path / "state" / "scheduled_tasks.json").write_text( json.dumps({"tasks": tasks}), encoding="utf-8", ) -def test_bgc_direct_identity_update_requires_complete_named_omission(tmp_path): - bc = _bg_fixture(tmp_path) - try: - # The knowledge header is separate from the exact body; both the - # delivered body and revision must preserve a CRLF source. - backlog = tmp_path / "memory" / "knowledge" / "improvement-backlog.md" - backlog.write_bytes( - backlog.read_bytes().replace(b"\r\n", b"\n").replace(b"\n", b"\r\n") - ) - context = bc._build_context() - assert "knowledge_read" in context and "improvement-backlog" in context - content = "I remain directly self-authoring after complete source materialization." - blocked = bc._execute_tool(_tool_call("update_identity", {"content": content}, "u1"), []) - assert "IDENTITY_UPDATE_ABSTAINED" in blocked - read = bc._execute_tool(_tool_call("knowledge_read", {"topic": "improvement-backlog"}, "r1"), []) - assert "background item 9" in read - updated = bc._execute_tool(_tool_call("update_identity", {"content": content}, "u2"), []) - assert updated.startswith("OK: identity updated") - journal = tmp_path / "memory" / "identity_journal.jsonl" - assert journal.exists() and content in journal.read_text(encoding="utf-8") - finally: - bc._tool_executor.shutdown(wait=False, cancel_futures=True) +def test_wake_context_discloses_a_malformed_recent_chat_gap(tmp_path): + context = _wake_context(tmp_path, chat_rows=[ + json.dumps({"chat_id": 1, "direction": "in", "text": "complete recent chat"}), + '{"direction":"in","text":"broken"', + ]) + assert "jsonl_malformed" in context -def test_bgc_source_mutation_after_read_cannot_authorize_identity_rewrite(tmp_path, monkeypatch): - bc = _bg_fixture(tmp_path) - try: - bc._build_context() - backlog = tmp_path / "memory" / "knowledge" / "improvement-backlog.md" - changed = backlog.read_bytes() + ( - b"\n### ibl-concurrent\n- summary: changed after materialization\n" - ) - real_read_bytes = pathlib.Path.read_bytes - target_reads = 0 - - def mutate_before_snapshot(self, *args, **kwargs): - nonlocal target_reads - if self == backlog: - target_reads += 1 - if target_reads == 1: - captured = real_read_bytes(self, *args, **kwargs) - self.write_bytes(changed) - return captured - return real_read_bytes(self, *args, **kwargs) - - monkeypatch.setattr(pathlib.Path, "read_bytes", mutate_before_snapshot) - bc._execute_tool(_tool_call("knowledge_read", {"topic": "improvement-backlog"}, "r1"), []) - result = bc._execute_tool( - _tool_call("update_identity", {"content": "must remain blocked"}, "u1"), [], - ) - assert target_reads >= 2 # reader capture plus current-source validation - assert "IDENTITY_UPDATE_ABSTAINED" in result - finally: - bc._tool_executor.shutdown(wait=False, cancel_futures=True) +def test_wake_context_carries_a_complete_dialogue_block_without_a_gap(tmp_path): + (tmp_path / "memory").mkdir(parents=True, exist_ok=True) + (tmp_path / "memory" / "dialogue_blocks.json").write_text(json.dumps([{ + "ts": "2026-08-21T00:00:00Z", "source": "consolidator", + "content": "Complete consolidated biography block.", + }]), encoding="utf-8") + _write_schedules(tmp_path, 8) + context = _wake_context(tmp_path) + assert "Complete consolidated biography block." in context + dialogue = context.split("## Dialogue History", 1)[1].split("\n## ", 1)[0] + assert "[MEMORY GAP]" not in dialogue -def test_bgc_source_snapshot_cannot_mix_text_and_digest(tmp_path, monkeypatch): - bc = _bg_fixture(tmp_path) - try: - backlog = tmp_path / "memory" / "knowledge" / "improvement-backlog.md" - backlog.write_bytes( - backlog.read_bytes().replace(b"\r\n", b"\n").replace(b"\n", b"\r\n") - ) - bc._build_context() - original = backlog.read_bytes() - changed = original + b"\r\n### ibl-concurrent\r\n- summary: changed bytes\r\n" - real_read_bytes = pathlib.Path.read_bytes - target_reads = 0 - - def oscillate_during_validation(self, *args, **kwargs): - nonlocal target_reads - if self == backlog: - target_reads += 1 - if target_reads == 1: - self.write_bytes(changed) - raw = real_read_bytes(self, *args, **kwargs) - self.write_bytes(original) - return raw - return real_read_bytes(self, *args, **kwargs) - - monkeypatch.setattr(pathlib.Path, "read_bytes", oscillate_during_validation) - materialized = bc._execute_tool( - _tool_call("knowledge_read", {"topic": "improvement-backlog"}, "r1"), [], - ) - assert materialized.endswith(changed.decode("utf-8")) - assert materialized.startswith("[Knowledge source]") - assert "improvement-backlog" not in bc._identity_source_reads - backlog.write_bytes(changed) - result = bc._execute_tool( - _tool_call("update_identity", {"content": "must remain blocked"}, "u1"), [], - ) - assert "IDENTITY_UPDATE_ABSTAINED" in result - finally: - bc._tool_executor.shutdown(wait=False, cancel_futures=True) +def test_wake_context_discloses_a_durable_dialogue_gap(tmp_path): + (tmp_path / "memory").mkdir(parents=True, exist_ok=True) + (tmp_path / "memory" / "dialogue_blocks.json").write_text(json.dumps([{ + "ts": "2026-08-21T00:00:00Z", "source": "consolidator", + "gap_id": "dialogue-gap-123", + "content": "[MEMORY GAP] A durable biography interval is unavailable.", + }]), encoding="utf-8") + context = _wake_context(tmp_path) + assert "## Dialogue History" in context and "[MEMORY GAP]" in context -def test_bgc_unavailable_named_omission_abstains_without_approval_flow(tmp_path): - bc = _bg_fixture(tmp_path) - try: - bc._build_context() - (tmp_path / "memory" / "knowledge" / "improvement-backlog.md").unlink() - content = "I retain direct authority but abstain when the named source is unavailable." - result = bc._execute_tool(_tool_call("update_identity", {"content": content}, "u1"), []) - assert "IDENTITY_UPDATE_ABSTAINED" in result - assert "approval" not in result.lower() - assert not (tmp_path / "memory" / "identity_journal.jsonl").exists() - finally: - bc._tool_executor.shutdown(wait=False, cancel_futures=True) - - -def test_bgc_malformed_recent_chat_gap_blocks_direct_identity_update(tmp_path): - bc = _bg_fixture(tmp_path, backlog_count=0) - try: - chat = tmp_path / "logs" / "chat.jsonl" - chat.write_text( - chat.read_text(encoding="utf-8") + '{"direction":"in","text":"broken"\n', - encoding="utf-8", - ) - context = bc._build_context() - assert "jsonl_malformed" in context - content = "I must not rewrite identity from a context with a known chat gap." - result = bc._execute_tool(_tool_call("update_identity", {"content": content}, "u1"), []) - assert "IDENTITY_UPDATE_ABSTAINED" in result - assert not (tmp_path / "memory" / "identity_journal.jsonl").exists() - finally: - bc._tool_executor.shutdown(wait=False, cancel_futures=True) - - -def test_bgc_complete_recent_chat_keeps_direct_identity_update_available(tmp_path): - bc = _bg_fixture(tmp_path, backlog_count=0) - try: - memory_dir = tmp_path / "memory" - memory_dir.mkdir(parents=True, exist_ok=True) - (memory_dir / "dialogue_blocks.json").write_text(json.dumps([{ - "ts": "2026-08-21T00:00:00Z", "source": "consolidator", - "content": "Complete consolidated biography block.", - }]), encoding="utf-8") - _write_schedules(tmp_path, 8) - bc._build_context() - content = "I retain direct identity authority with complete ordinary context." - result = bc._execute_tool(_tool_call("update_identity", {"content": content}, "u1"), []) - assert result.startswith("OK: identity updated") - assert content in (tmp_path / "memory" / "identity_journal.jsonl").read_text(encoding="utf-8") - finally: - bc._tool_executor.shutdown(wait=False, cancel_futures=True) - - -def test_bgc_durable_dialogue_gap_blocks_direct_identity_update(tmp_path): - bc = _bg_fixture(tmp_path, backlog_count=0) - try: - memory_dir = tmp_path / "memory" - memory_dir.mkdir(parents=True, exist_ok=True) - (memory_dir / "dialogue_blocks.json").write_text(json.dumps([{ - "ts": "2026-08-21T00:00:00Z", "source": "consolidator", - "gap_id": "dialogue-gap-123", - "content": "[MEMORY GAP] A durable biography interval is unavailable.", - }]), encoding="utf-8") - context = bc._build_context() - assert "## Dialogue History" in context and "[MEMORY GAP]" in context - content = "I must not rewrite identity across a known durable biography gap." - result = bc._execute_tool(_tool_call("update_identity", {"content": content}, "u1"), []) - assert "IDENTITY_UPDATE_ABSTAINED" in result - assert "dialogue-gap-123" in result - assert not (memory_dir / "identity_journal.jsonl").exists() - finally: - bc._tool_executor.shutdown(wait=False, cancel_futures=True) - - -def test_bgc_scheduled_tasks_omission_blocks_direct_identity_update(tmp_path): - bc = _bg_fixture(tmp_path, backlog_count=0) - try: - _write_schedules(tmp_path, 9) - context = bc._build_context() - assert '"omitted_count": 1' in context - content = "I must not rewrite identity from an incomplete standing-schedule digest." - result = bc._execute_tool(_tool_call("update_identity", {"content": content}, "u1"), []) - assert "IDENTITY_UPDATE_ABSTAINED" in result - assert "scheduled-tasks" in result - assert not (tmp_path / "memory" / "identity_journal.jsonl").exists() - finally: - bc._tool_executor.shutdown(wait=False, cancel_futures=True) - - -def test_bgc_observation_gap_blocks_direct_identity_update(tmp_path): - bc = _bg_fixture(tmp_path, backlog_count=0) - try: - store = tmp_path / "state" / "consciousness_observations.jsonl" - store.write_text( - '{"op":"enqueue","id":"valid","source":"runtime",' - '"kind":"text","time":"2026-08-21T00:00:00Z",' - '"payload":"known row","ref":null}\n' - '{"op":"enqueue","id":"broken"\n', - encoding="utf-8", - ) - context = bc._build_context() - assert "source_complete=False" in context - assert "state/consciousness_observations.jsonl" in context - assert "background-observations" in context - content = "I must not rewrite identity across a known observation-source gap." - result = bc._execute_tool(_tool_call("update_identity", {"content": content}, "u1"), []) - assert "IDENTITY_UPDATE_ABSTAINED" in result - assert "background-observations" in result - assert not (tmp_path / "memory" / "identity_journal.jsonl").exists() - finally: - bc._tool_executor.shutdown(wait=False, cancel_futures=True) - - -def test_bgc_omitted_observation_rows_block_direct_identity_update(tmp_path): - bc = _bg_fixture(tmp_path, backlog_count=0) - try: - for index in range(11): - assert bc.inject_observation( - f"observation-{index}", observation_id=f"omitted-{index}" - ) - context = bc._build_context() - assert "omitted=1" in context - assert "source_complete=False" in context - assert "background-observations" in context - result = bc._execute_tool( - _tool_call( - "update_identity", - {"content": "Do not rewrite identity from omitted observations."}, - "u1", - ), - [], - ) - assert "IDENTITY_UPDATE_ABSTAINED" in result - assert "background-observations" in result - assert not (tmp_path / "memory" / "identity_journal.jsonl").exists() - finally: - bc._tool_executor.shutdown(wait=False, cancel_futures=True) - - -def test_bgc_payload_truncation_blocks_direct_identity_update(tmp_path): - bc = _bg_fixture(tmp_path, backlog_count=0) - try: - assert bc.inject_observation( - "p" * 2_000, observation_id="payload-truncated" - ) - context = bc._build_context() - assert "payload omitted" in context - assert "source_complete=False" in context - assert "background-observations" in context - result = bc._execute_tool( - _tool_call( - "update_identity", - {"content": "Do not rewrite identity from a truncated payload."}, - "u1", - ), - [], - ) - assert "IDENTITY_UPDATE_ABSTAINED" in result - assert "background-observations" in result - assert not (tmp_path / "memory" / "identity_journal.jsonl").exists() - finally: - bc._tool_executor.shutdown(wait=False, cancel_futures=True) - - -def test_bgc_aggregate_projection_truncation_blocks_direct_identity_update(tmp_path): - bc = _bg_fixture(tmp_path, backlog_count=0) - try: - for index in range(10): - assert bc.inject_observation( - "small", - observation_id=f"aggregate-{index}", - source="s" * 1_400, - ) - context = bc._build_context() - assert "projection truncated" in context - assert "source_complete=False" in context - assert "background-observations" in context - result = bc._execute_tool( - _tool_call( - "update_identity", - {"content": "Do not rewrite identity from an aggregate projection."}, - "u1", - ), - [], - ) - assert "IDENTITY_UPDATE_ABSTAINED" in result - assert "background-observations" in result - assert not (tmp_path / "memory" / "identity_journal.jsonl").exists() - finally: - bc._tool_executor.shutdown(wait=False, cancel_futures=True) - - -def test_bgc_complete_observation_source_keeps_direct_identity_update_available(tmp_path): - bc = _bg_fixture(tmp_path, backlog_count=0) - try: - assert bc.inject_observation( - "complete observation", observation_id="complete-observation" - ) - context = bc._build_context() - assert "source_complete=True" in context - assert "complete-observation" in context - content = "I retain direct identity authority with a complete observation source." - result = bc._execute_tool(_tool_call("update_identity", {"content": content}, "u1"), []) - assert result.startswith("OK: identity updated") - assert content in (tmp_path / "memory" / "identity_journal.jsonl").read_text( - encoding="utf-8" - ) - finally: - bc._tool_executor.shutdown(wait=False, cancel_futures=True) +def test_wake_context_discloses_an_omitted_schedule_count(tmp_path): + _write_schedules(tmp_path, 9) + context = _wake_context(tmp_path) + assert '"omitted_count": 1' in context diff --git a/tests/test_evolution_status.py b/tests/test_evolution_status.py index 28177e03c..febe8847b 100644 --- a/tests/test_evolution_status.py +++ b/tests/test_evolution_status.py @@ -1,7 +1,6 @@ """Tests for evolution/consciousness status snapshots.""" import json -from unittest.mock import MagicMock, patch from starlette.applications import Starlette from starlette.routing import Route @@ -81,24 +80,22 @@ def test_evolution_status_reports_budget_stop_when_disabled_after_run(monkeypatc assert snapshot["budget_remaining_usd"] == 1.25 -def test_consciousness_status_snapshot_exposes_runtime_fields(): +def test_consciousness_status_snapshot_exposes_the_alarm_facts(monkeypatch, tmp_path): + from ouroboros import consciousness as clock_module from ouroboros.consciousness import BackgroundConsciousness + from supervisor import state - with patch.object(BackgroundConsciousness, "_build_registry", return_value=MagicMock()): - consciousness = BackgroundConsciousness( - drive_root=MagicMock(), - repo_dir=MagicMock(), - event_queue=None, - owner_chat_id_fn=lambda: 1, - ) + monkeypatch.setattr(state, "load_state", lambda: {"bg_consciousness_enabled": True, "owner_chat_id": 1}) + monkeypatch.setattr(clock_module, "allowance_window", lambda root, now=None: { + "status": "available", "limit_usd": 20.0, "accounted_usd": 3.0, "remaining_usd": 17.0, "resets_at": ""}) + monkeypatch.setattr(BackgroundConsciousness, "_running_roots", staticmethod(lambda: 0)) + clock = BackgroundConsciousness(tmp_path, tmp_path / "repo", lambda: 1, now=1_800_000_000.0) + clock.notify("task_finished:t1:completed") + snapshot = clock.status_snapshot() - consciousness.pause() - consciousness._next_wakeup_sec = 180 - snapshot = consciousness.status_snapshot() - - assert snapshot["paused"] is True - assert snapshot["next_wakeup_sec"] == 180 - assert snapshot["last_idle_reason"] == "paused_by_active_task" + assert snapshot["enabled"] is True and snapshot["pending_reason"] == "task_finished:t1:completed" + assert snapshot["next_wake_at"] and snapshot["last_wake_at"] == "" and snapshot["live_wake_task_id"] == "" + assert snapshot["spent_24h_usd"] == 3.0 and snapshot["daily_usd"] == 20.0 def test_evolution_data_strips_legacy_checkpoint_result_status(tmp_path, monkeypatch): diff --git a/tests/test_identity_wording.py b/tests/test_identity_wording.py index c6326b558..60e32284d 100644 --- a/tests/test_identity_wording.py +++ b/tests/test_identity_wording.py @@ -12,7 +12,7 @@ def test_prompts_do_not_infer_current_human_from_authors(): assert "my human" in system assert "I do not know their name" in system assert "README, BIBLE, git history, or author" in system - assert "Messages From My Human" in consciousness + assert "your human" in consciousness and "the user" not in consciousness assert "I do not yet know my human's name or profile" in memory assert "Anton" not in system assert "Razzhigaev" not in system diff --git a/tests/test_max_tokens_constants.py b/tests/test_max_tokens_constants.py index 165d30120..6a96660db 100644 --- a/tests/test_max_tokens_constants.py +++ b/tests/test_max_tokens_constants.py @@ -83,7 +83,6 @@ def test_summary_and_background_token_budgets(): "ouroboros/reflection.py": "max_tokens=16384", "ouroboros/post_task_synthesis.py": "max_tokens=16384", "ouroboros/tools/skill_publish.py": "max_tokens=8192", - "ouroboros/consciousness.py": "max_tokens=65536", } for path, needle in expectations.items(): src = Path(path).read_text(encoding="utf-8").replace(" ", "") diff --git a/tests/test_memory_people_prompts.py b/tests/test_memory_people_prompts.py index 11ad35bfc..ffc5c4cc1 100644 --- a/tests/test_memory_people_prompts.py +++ b/tests/test_memory_people_prompts.py @@ -103,30 +103,27 @@ def test_memory_vocabulary_is_not_dressed_up_as_tool_names(): assert f"`{word}`" not in text, f"{rel} backticks {word}" -def test_background_checklist_maintains_understanding_of_people(): - consciousness = _read("prompts/CONSCIOUSNESS.md") - item = " ".join( - consciousness.split("3. **Knowledge base gaps**", 1)[1].split("\n\n", 1)[0].split() - ).lower() +def test_wake_template_maintains_understanding_of_people(): + """The wake-up message (an ordinary Main turn's user text) keeps the commitment: + revise the existing note about a person rather than minting a new one (P12).""" + template = " ".join(_read("prompts/CONSCIOUSNESS.md").split()).lower() - assert "person" in item or "people" in item - # Revise the current note rather than minting a new one (P12: read first). - assert "knowledge_read" in item and "knowledge_write" in item - assert "overview" in item + assert "people you talk with" in template + assert "knowledge_read" in template and "knowledge_write" in template + assert "rather than minting a new one" in template -def test_background_hygiene_checks_beliefs_about_people_too(): - hygiene = _section(_read("prompts/CONSCIOUSNESS.md"), "Memory Hygiene").lower() +def test_wake_template_resolves_contradictions_about_people_too(): + template = " ".join(_read("prompts/CONSCIOUSNESS.md").split()).lower() - assert "knowledge notes" in hygiene - assert "people" in hygiene + assert "contradictions" in template and "about the people you talk with" in template -def test_background_prompt_addresses_its_human_not_a_user(): +def test_wake_template_addresses_its_human_not_a_user(): consciousness = _read("prompts/CONSCIOUSNESS.md") - assert "Write to my human proactively" in consciousness - assert "Message the user proactively" not in consciousness + assert "write to your human" in consciousness + assert "the user" not in consciousness def test_reflection_nominates_learning_about_people_as_well_as_itself(): diff --git a/tests/test_native_conversation_activity.py b/tests/test_native_conversation_activity.py index 86ea544d4..7ba67a2f6 100644 --- a/tests/test_native_conversation_activity.py +++ b/tests/test_native_conversation_activity.py @@ -150,25 +150,30 @@ def test_restart_census_keeps_native_execution_after_owner_boundary(): assert _live_running_task_ids(SimpleNamespace(RUNNING={})) == [] -def test_consciousness_remains_paused_until_all_native_work_returns(): +def _mind(): + """An alarm clock that reads liveness off the census alone (no state, no lane).""" from ouroboros.consciousness import BackgroundConsciousness mind = object.__new__(BackgroundConsciousness) - mind._paused = False + mind._last_wake_task_id = "" + return mind + + +def test_consciousness_sees_an_owner_turn_live_until_all_native_work_returns(): + mind = _mind() registry = get_direct_activity_registry() registry.register("first", 1) registry.register("second", 2) - assert mind.is_paused + assert mind.live_turns() == ("", True) registry.unregister("first") - assert mind.is_paused + assert mind.live_turns() == ("", True) registry.unregister("second") - assert not mind.is_paused + assert mind.live_turns() == ("", False) def test_native_post_task_retains_activity_and_delivers_answer_early(monkeypatch, tmp_path): """Actual synthesis dispatch must stay owned after the ordinary final answer.""" from ouroboros import agent_task_pipeline as pipeline, post_task_evolution - from ouroboros.consciousness import BackgroundConsciousness from ouroboros.gateway.settings import _has_running_agent_tasks, _has_started_agent_tasks from ouroboros.post_task_checkpoint import post_task_synthesis_in_flight from ouroboros.server_restart import _live_running_task_ids @@ -195,8 +200,7 @@ def test_native_post_task_retains_activity_and_delivers_answer_early(monkeypatch monkeypatch.setattr(pipeline, name, lambda *a, **kw: None) monkeypatch.setattr(post_task_evolution, "maybe_promote", lambda *a, **kw: None) env = SimpleNamespace(repo_dir=tmp_path / "repo", drive_root=tmp_path) - mind = object.__new__(BackgroundConsciousness) - mind._paused = False + mind = _mind() class Actor: def handle_task(self, task): @@ -237,7 +241,7 @@ def test_native_post_task_retains_activity_and_delivers_answer_early(monkeypatch assert workers.drain_repo_writers(0) == [task_id] assert _live_running_task_ids(SimpleNamespace(RUNNING={})) == [task_id] assert _has_running_agent_tasks() and _has_started_agent_tasks() - assert mind.is_paused + assert mind.live_turns() == ("", True) # Production early delivery ran before synthesis. The terminal # completion stays buffered until post-task work returns. early = bus.get_nowait() @@ -256,7 +260,7 @@ def test_native_post_task_retains_activity_and_delivers_answer_early(monkeypatch assert workers.drain_repo_writers(0) == [] assert _live_running_task_ids(SimpleNamespace(RUNNING={})) == [] assert not _has_running_agent_tasks() and not _has_started_agent_tasks() - assert not mind.is_paused + assert mind.live_turns() == ("", False) assert load_task_result(tmp_path, task_id)["root_phase_checkpoint"]["post_task_synthesis"] == "completed" # Retained final and early final use the existing delivery identity; # the supervisor deduplicates them. task_done reaches the bus last. diff --git a/tests/test_openai_chat_dispatch.py b/tests/test_openai_chat_dispatch.py index 19d9525f3..6eefba064 100644 --- a/tests/test_openai_chat_dispatch.py +++ b/tests/test_openai_chat_dispatch.py @@ -729,132 +729,6 @@ def test_function_origin_remains_schema_tolerant(tmp_path): assert tools.calls == [("probe", {"marker": "wrong"})] -def test_background_custom_schema_error_never_reaches_registry(): - from ouroboros.consciousness import BackgroundConsciousness - - message, receipts = _invalid_custom_exchange() - instance = object.__new__(BackgroundConsciousness) - result = instance._execute_tool(message["tool_calls"][0], [], receipts[0]) - assert "TOOL_ARG_ERROR" in result - - -def test_background_two_round_custom_error_continuation(monkeypatch, tmp_path): - from concurrent.futures import ThreadPoolExecutor - - from ouroboros import consciousness - from ouroboros.consciousness import BackgroundConsciousness - - read_tool = { - "type": "function", - "function": { - "name": "read_file", - "description": "Read one allowed path.", - "parameters": { - "type": "object", - "properties": {"path": {"const": "allowed"}}, - "required": ["path"], - "additionalProperties": False, - }, - }, - } - invalid, receipts = _invalid_custom_exchange( - read_tool, - '{"path":"wrong"}', - ) - observed_messages = [] - - def fake_chat_observed(_client, **kwargs): - observed_messages.append(copy.deepcopy(kwargs["messages"])) - if len(observed_messages) == 1: - return copy.deepcopy(invalid), { - **_receipt_usage(receipts), - "cost": 0.0, - } - return {"role": "assistant", "content": "corrected"}, {"cost": 0.0} - - registry_calls = [] - registry = SimpleNamespace( - _ctx=SimpleNamespace(), - get_timeout=lambda _name: 1, - execute=lambda name, args: registry_calls.append((name, args)) or "executed", - ) - instance = object.__new__(BackgroundConsciousness) - instance._build_context = lambda: "context" - instance._tool_schemas = lambda: [read_tool] - instance._llm = SimpleNamespace(_resolve_remote_target=lambda _model: _target()) - instance._drive_root = tmp_path - instance._max_bg_rounds = 2 - instance._paused = False - instance._emit_live_log = lambda *_a, **_k: None - instance._emit_progress = lambda _content: None - instance._bg_spent_usd = 0.0 - instance._check_budget = lambda: True - instance._event_queue = None - instance._last_idle_reason = "" - instance._next_wakeup_sec = 300 - instance._wakeup_max = 3600 - instance._owner_chat_id_fn = lambda: None - instance._registry = registry - instance._tool_executor = ThreadPoolExecutor(max_workers=1) - (tmp_path / "logs").mkdir() - - monkeypatch.setattr( - consciousness, - "get_consciousness_model", - lambda: "openai::future-model-without-prefix", - ) - monkeypatch.setattr(consciousness, "resolve_effort", lambda _slot: "medium") - monkeypatch.setattr(dispatch, "projected_context_size_bytes", lambda *_a, **_k: 1) - monkeypatch.setattr( - "ouroboros.llm_observability.chat_observed", - fake_chat_observed, - ) - try: - assert instance._think_scoped() is True - finally: - instance._tool_executor.shutdown(wait=True) - - assert registry_calls == [] - assert len(observed_messages) == 2 - assert observed_messages[1][-1]["role"] == "tool" - assert "TOOL_ARG_ERROR" in observed_messages[1][-1]["content"] - - -def test_background_admission_counts_physical_custom_projection(monkeypatch, tmp_path): - from ouroboros import consciousness - from ouroboros.consciousness import BackgroundConsciousness - - logs = tmp_path / "logs" - logs.mkdir() - instance = object.__new__(BackgroundConsciousness) - instance._build_context = lambda: "context" - instance._tool_schemas = _tools - instance._llm = SimpleNamespace(_resolve_remote_target=lambda _model: _target()) - instance._drive_root = tmp_path - instance._max_bg_rounds = 1 - instance._paused = False - instance._last_idle_reason = "" - observed = {} - - def oversized(messages, tools, **kwargs): - observed.update(kwargs) - assert messages[0]["content"] == "context" - assert tools == _tools() - return consciousness.BG_CONTEXT_MAX_CHARS + 1 - - monkeypatch.setattr(dispatch, "projected_context_size_bytes", oversized) - monkeypatch.setattr(consciousness, "resolve_effort", lambda _slot: "medium") - monkeypatch.setattr( - consciousness, - "get_consciousness_model", - lambda: "openai::future-model-without-prefix", - ) - - assert instance._think_scoped() is False - assert instance._last_idle_reason == "context_overflow" - assert observed == {"provider": "openai", "reasoning_effort": "medium"} - - def test_structured_compaction_returns_one_bounded_tool_error_continuation(monkeypatch, tmp_path): message, receipts = _invalid_custom_exchange( context_compaction._CONTEXT_SUMMARIES_TOOL, diff --git a/tests/test_openai_chat_dispatch_review.py b/tests/test_openai_chat_dispatch_review.py index 8628cf49b..56d913200 100644 --- a/tests/test_openai_chat_dispatch_review.py +++ b/tests/test_openai_chat_dispatch_review.py @@ -5,9 +5,7 @@ from __future__ import annotations import copy import json import pathlib -from types import SimpleNamespace -import ouroboros.consciousness as consciousness import ouroboros.context_compaction as context_compaction import ouroboros.llm as llm_module import ouroboros.llm_observability as llm_observability @@ -103,28 +101,6 @@ class _QueuedLLM: return copy.deepcopy(message), usage -def _background_instance(tmp_path, llm, tools, *, rounds=2): - instance = object.__new__(consciousness.BackgroundConsciousness) - instance._build_context = lambda: "context" - instance._tool_schemas = lambda: copy.deepcopy(tools) - instance._llm = llm - instance._drive_root = tmp_path - instance._max_bg_rounds = rounds - instance._paused = False - instance._emit_live_log = lambda *_a, **_k: None - instance._emit_progress = lambda _content: None - instance._bg_spent_usd = 0.0 - instance._check_budget = lambda: True - instance._event_queue = None - instance._last_idle_reason = "" - instance._next_wakeup_sec = 300 - instance._wakeup_max = 3600 - instance._owner_chat_id_fn = lambda: None - instance._registry = SimpleNamespace(_ctx=SimpleNamespace()) - (tmp_path / "logs").mkdir(exist_ok=True) - return instance - - def _capture_persistence(monkeypatch): persisted = [] monkeypatch.setattr( @@ -143,42 +119,6 @@ def _response_payloads(persisted): ] -def _pin_remote_background(monkeypatch): - monkeypatch.setattr( - consciousness, - "get_consciousness_model", - lambda: "openai::future-model-without-prefix", - ) - monkeypatch.setattr(consciousness, "resolve_effort", lambda _slot: "medium") - - -def test_background_chat_observed_persists_public_usage_and_returns_receipt( - monkeypatch, - tmp_path, -): - tool = _read_tool() - invalid, first_usage = _custom_exchange(tool, '{"path":"wrong"}') - llm = _QueuedLLM([ - (invalid, first_usage), - ({"role": "assistant", "content": "corrected"}, {"cost": 0.0}), - ]) - instance = _background_instance(tmp_path, llm, [tool]) - persisted = _capture_persistence(monkeypatch) - _pin_remote_background(monkeypatch) - - assert instance._think_scoped() is True - assert len(llm.requests) == 2 - assert llm.requests[1]["messages"][-1]["role"] == "tool" - assert "TOOL_ARG_ERROR" in llm.requests[1]["messages"][-1]["content"] - assert dispatch.CUSTOM_RECEIPTS_USAGE_KEY not in first_usage - response_payloads = _response_payloads(persisted) - assert response_payloads[0]["usage"]["request_wire"] - assert all( - dispatch.CUSTOM_RECEIPTS_USAGE_KEY not in item["usage"] - for item in response_payloads - ) - - def test_compaction_chat_observed_persists_public_usage_and_returns_receipt( monkeypatch, tmp_path, @@ -302,49 +242,6 @@ def test_history_body_error_cannot_advance_the_dialect_ladder(): ) is None -def test_background_remeasures_growth_before_every_physical_send(monkeypatch, tmp_path): - tool = _read_tool() - first = { - "role": "assistant", - "content": "", - "tool_calls": [{ - "id": "call-large", - "type": "function", - "function": {"name": "read_file", "arguments": '{"path":"allowed"}'}, - }], - } - llm = _QueuedLLM([ - (first, {"cost": 0.0}), - ({"role": "assistant", "content": "must not send"}, {"cost": 0.0}), - ]) - instance = _background_instance(tmp_path, llm, [tool]) - instance._execute_tool = ( - lambda *_a, **_k: "x" * (consciousness.BG_CONTEXT_MAX_CHARS + 100) - ) - _pin_remote_background(monkeypatch) - measured = [] - real_measure = dispatch.projected_context_size_bytes - - def measure(*args, **kwargs): - result = real_measure(*args, **kwargs) - measured.append(result) - return result - - monkeypatch.setattr(dispatch, "projected_context_size_bytes", measure) - - assert instance._think_scoped() is False - assert len(llm.requests) == 1 - assert len(measured) == 2 - assert measured[0] < consciousness.BG_CONTEXT_MAX_CHARS - assert measured[1] > consciousness.BG_CONTEXT_MAX_CHARS - assert instance._last_idle_reason == "context_overflow" - events = [ - json.loads(line) - for line in (tmp_path / "logs" / "events.jsonl").read_text().splitlines() - ] - assert events[-1]["type"] == "consciousness_context_overflow" - - def _serialized_projection(messages, tools): return json.dumps( {"messages": messages, "tools": tools}, diff --git a/tests/test_owner_live_delivery.py b/tests/test_owner_live_delivery.py index ccc0e2acd..d0b143d03 100644 --- a/tests/test_owner_live_delivery.py +++ b/tests/test_owner_live_delivery.py @@ -48,28 +48,6 @@ class TestDeliverOwnerEvent: assert mode == "deferred" assert ctx.pending_events[0]["root_task_id"] == "t-root" - def test_background_consciousness_always_deferred_and_unstamped(self): - from ouroboros.tool_capabilities import BACKGROUND_DELEGATION_ROLE - - q = _Queue() - ctx = _ctx(event_queue=q, meta={"delegation_role": BACKGROUND_DELEGATION_ROLE}) - mode = deliver_owner_event(ctx, {"type": "send_message", "chat_id": 1, "text": "x"}) - assert mode == "deferred" - assert q.items == [] - # BG frames stay exactly as before the seam: buffered, no pseudo-lineage. - assert "task_id" not in ctx.pending_events[0] - - def test_consciousness_stamps_the_shared_background_role(self): - # Literal-drift pin: the producer (consciousness) and the gate - # (owner_delivery) must share ONE constant, not two literals. - import inspect - - from ouroboros import consciousness - - src = inspect.getsource(consciousness) - assert "BACKGROUND_DELEGATION_ROLE" in src - assert '"delegation_role": "background"' not in src - def test_retry_duplicates_are_accepted_policy(self): # A live-delivered frame from attempt 1 is not recalled; a retried # task re-narrates with a fresh ctx and delivers again. The seam diff --git a/tests/test_persistence_inventory.py b/tests/test_persistence_inventory.py index eec8ac8f4..1f219cee3 100644 --- a/tests/test_persistence_inventory.py +++ b/tests/test_persistence_inventory.py @@ -558,7 +558,11 @@ def scan_data_paths(root: pathlib.Path = REPO) -> frozenset[str]: # 290 -> 291: the supervisor's off-lock projection of live direct-chat roots # (``state/direct_roots.json``, ``supervisor/direct_roots.py``) is the one new # durable plane of the structural-health train; it has its own row in section 2. -EXPECTED_SCAN_PATHS = 291 # Linked project knowledge history is a dynamic validated shelf. +# 291 -> 290: the Background Consciousness redesign retired the observation inbox +# and its startup fold; the archive segments ``archive/consciousness_observations_`` +# left the population and the one-time ``archive/consciousness_observations.jsonl`` +# move target (``ouroboros/consciousness.py``) took their place. +EXPECTED_SCAN_PATHS = 290 # Linked project knowledge history is a dynamic validated shelf. # Scanned paths that must always be present — guards the scanner itself # against a silent regression that would shrink coverage while keeping counts diff --git a/tests/test_quiz_answer.py b/tests/test_quiz_answer.py index 86dafc244..8d16c56a1 100644 --- a/tests/test_quiz_answer.py +++ b/tests/test_quiz_answer.py @@ -423,12 +423,6 @@ def test_escalate_settled_parent_is_a_typed_dead_end(tmp_path, monkeypatch): assert out.startswith("⚠️ ESCALATE_PARENT_SETTLED") -def test_escalate_background_refused(tmp_path): - ctx = _tool_ctx(tmp_path, task_id="bg-consciousness", role="background") - out = _escalate(ctx, question="?", options=["a", "b"], assumption="a") - assert out.startswith("⚠️ ESCALATE_UNAVAILABLE") - - def test_escalate_invalid_payload_is_typed(tmp_path): ctx = _tool_ctx(tmp_path) out = _escalate(ctx, question="?", options=["only-one"], assumption="a") @@ -898,14 +892,12 @@ def test_recommended_option_rides_the_card_the_projection_and_the_parent_frame(t def test_escalate_refusals_are_typed_per_branch_and_a_headless_root_still_asks(tmp_path): - """Verification only: the three real refusal branches as the predicate is written. - Background consciousness is refused; a live direct conversation (including one with - no continuation owner) is refused; REQUIRED waiting without a live continuation owner - is refused. A headless root without owner_wait_callback is NOT refused for an optional - question: it mints the ordinary card and continues under its assumption.""" - background = _tool_ctx(tmp_path, task_id="bg", role="background") - out = _escalate(background, question="?", options=["a", "b"], assumption="a") - assert out.startswith("⚠️ ESCALATE_UNAVAILABLE: background consciousness cannot escalate") + """Verification only: the real refusal branches as the predicate is written. + A live direct conversation (including one with no continuation owner) is refused; + REQUIRED waiting without a live continuation owner is refused. A headless root + without owner_wait_callback is NOT refused for an optional question: it mints the + ordinary card and continues under its assumption. (A consciousness wake-up is an + ordinary root here — nothing refuses it by role.)""" direct = _tool_ctx(tmp_path) direct.is_direct_chat = True out = _escalate(direct, question="?", options=["a", "b"], assumption="a") diff --git a/tests/test_repo_read_limits.py b/tests/test_repo_read_limits.py index d4bef875e..21f720861 100644 --- a/tests/test_repo_read_limits.py +++ b/tests/test_repo_read_limits.py @@ -4,7 +4,7 @@ Also covers the core governance artifact invariants introduced in the 'Core Governance Artifacts' section of docs/DEVELOPMENT.md: - repo_read default max_lines raised to 2000 so ARCHITECTURE.md is readable in a single call. - - BackgroundConsciousness._build_context() includes ARCHITECTURE.md. + - the wake-up's Main context (build_llm_messages) includes ARCHITECTURE.md. - Triad review prompt includes ARCHITECTURE.md even when not touched. - DEVELOPMENT.md contains the core governance artifact invariant rule. """ @@ -228,92 +228,75 @@ def test_repo_read_can_read_architecture_md_in_one_call(tmp_path): assert f"line {n_lines}" in result -def test_consciousness_context_includes_architecture_md(tmp_path): - """BackgroundConsciousness._build_context must include ARCHITECTURE.md section.""" - import queue - from unittest.mock import patch, MagicMock +def _wake_context(tmp_path): + """The system text a consciousness wake-up gets: Main's own builder over a wake-shaped task.""" + from ouroboros.context import build_llm_messages + from ouroboros.memory import Memory - # Minimal fake repo tree + class FakeEnv: + def drive_path(self, p): + return tmp_path / "data" / p + + def repo_path(self, p): + return tmp_path / "repo" / p + + @property + def repo_dir(self): + return tmp_path / "repo" + + @property + def drive_root(self): + return tmp_path / "data" + + drive_root = tmp_path / "data" + for rel in ("logs", "state", "memory"): + (drive_root / rel).mkdir(parents=True, exist_ok=True) + (drive_root / "state" / "state.json").write_text("{}", encoding="utf-8") + (tmp_path / "repo" / "prompts").mkdir(parents=True, exist_ok=True) + (tmp_path / "repo" / "prompts" / "SYSTEM.md").write_text("System prompt", encoding="utf-8") + task = {"id": "wake1", "type": "task", "text": "[Wake-up · heartbeat]", "_is_direct_chat": True, + "metadata": {"initiator": "consciousness", "usage_category": "consciousness", + "consciousness_autonomy": "act"}} + messages, _cap = build_llm_messages(env=FakeEnv(), memory=Memory(drive_root=drive_root, repo_dir=tmp_path / "repo"), task=task) + return "\n\n".join(block["text"] for block in messages[0]["content"]) + + +def test_wake_context_includes_architecture_md(tmp_path): + """A consciousness wake-up reads the same governance artifacts as any Main turn.""" repo_dir = tmp_path / "repo" (repo_dir / "docs").mkdir(parents=True) (repo_dir / "BIBLE.md").write_text("# BIBLE", encoding="utf-8") (repo_dir / "docs" / "ARCHITECTURE.md").write_text( "# ARCHITECTURE\n\nThis is the architecture doc.", encoding="utf-8" ) - (repo_dir / "prompts" / "CONSCIOUSNESS.md").mkdir(parents=True, exist_ok=True) if False else None - # Consciousness prompt (optional — can be missing) - drive_root = tmp_path / "data" - (drive_root / "logs").mkdir(parents=True) - (drive_root / "state").mkdir(parents=True) - (drive_root / "state" / "state.json").write_text("{}", encoding="utf-8") - (drive_root / "memory").mkdir(parents=True) - - eq = queue.Queue() - - from ouroboros.consciousness import BackgroundConsciousness - - with patch.object(BackgroundConsciousness, "_build_registry", return_value=MagicMock()), \ - patch("ouroboros.consciousness.build_memory_sections", return_value=[]), \ - patch("ouroboros.consciousness.build_health_invariants", return_value=""), \ - patch("ouroboros.consciousness.build_runtime_section", return_value="## Runtime\n\nok"), \ - patch("ouroboros.consciousness.build_recent_sections", return_value=[]): - bc = BackgroundConsciousness( - drive_root=drive_root, - repo_dir=repo_dir, - event_queue=eq, - owner_chat_id_fn=lambda: 1, - ) - context = bc._build_context() + context = _wake_context(tmp_path) assert "## ARCHITECTURE.md" in context, ( - "BackgroundConsciousness._build_context() must include a '## ARCHITECTURE.md' section. " + "the wake-up's Main context must include a '## ARCHITECTURE.md' section. " "This is a core governance artifact — see docs/DEVELOPMENT.md." ) assert "This is the architecture doc." in context -def test_consciousness_context_architecture_before_knowledge_base(tmp_path): - """ARCHITECTURE.md section must come before knowledge base in consciousness context.""" - import queue - from unittest.mock import patch, MagicMock - +def test_wake_context_architecture_before_knowledge_base(tmp_path): + """ARCHITECTURE.md section must come before knowledge base in the wake-up's context.""" repo_dir = tmp_path / "repo" (repo_dir / "docs").mkdir(parents=True) (repo_dir / "BIBLE.md").write_text("# BIBLE", encoding="utf-8") (repo_dir / "docs" / "ARCHITECTURE.md").write_text("# ARCH CONTENT", encoding="utf-8") - - drive_root = tmp_path / "data" - (drive_root / "logs").mkdir(parents=True) - (drive_root / "state").mkdir(parents=True) - (drive_root / "state" / "state.json").write_text("{}", encoding="utf-8") - (drive_root / "memory").mkdir(parents=True) - kb = drive_root / "memory" / "knowledge" + kb = tmp_path / "data" / "memory" / "knowledge" kb.mkdir(parents=True) (kb / "index-full.md").write_text("# Knowledge base index", encoding="utf-8") - eq = queue.Queue() - from ouroboros.consciousness import BackgroundConsciousness - - with patch.object(BackgroundConsciousness, "_build_registry", return_value=MagicMock()), \ - patch("ouroboros.consciousness.build_memory_sections", return_value=[]), \ - patch("ouroboros.consciousness.build_health_invariants", return_value=""), \ - patch("ouroboros.consciousness.build_runtime_section", return_value="## Runtime\n\nok"), \ - patch("ouroboros.consciousness.build_recent_sections", return_value=[]): - bc = BackgroundConsciousness( - drive_root=drive_root, - repo_dir=repo_dir, - event_queue=eq, - owner_chat_id_fn=lambda: 1, - ) - context = bc._build_context() + context = _wake_context(tmp_path) arch_pos = context.find("## ARCHITECTURE.md") kb_pos = context.find("## Knowledge base") - assert arch_pos != -1, "ARCHITECTURE.md section not found in consciousness context" + assert arch_pos != -1, "ARCHITECTURE.md section not found in the wake-up's context" if kb_pos != -1: assert arch_pos < kb_pos, ( - "ARCHITECTURE.md must appear before the knowledge base in consciousness context" + "ARCHITECTURE.md must appear before the knowledge base in the wake-up's context" ) @@ -394,70 +377,39 @@ def test_governance_doc_load_emits_explicit_omission_marker_on_missing(tmp_path) # Never raises — the function still degrades gracefully. -def test_consciousness_logs_warning_when_architecture_md_missing(tmp_path): - """BackgroundConsciousness._build_context must log a warning when ARCHITECTURE.md is absent. +def test_wake_context_logs_warning_when_architecture_md_missing(tmp_path, caplog): + """The Main context builder must log a warning when ARCHITECTURE.md is absent — the + wake-up (an ordinary Main turn) inherits it. Per the Core Governance Artifacts invariant in docs/DEVELOPMENT.md: 'Log a warning if the file is missing or unavailable — do not silently skip.' - - Uses a fresh MagicMock() per getLogger call to avoid mutating real logger singletons. """ - import queue - from unittest.mock import patch, MagicMock + import logging repo_dir = tmp_path / "repo" (repo_dir / "docs").mkdir(parents=True) (repo_dir / "BIBLE.md").write_text("# BIBLE", encoding="utf-8") # Deliberately do NOT create docs/ARCHITECTURE.md - drive_root = tmp_path / "data" - (drive_root / "logs").mkdir(parents=True) - (drive_root / "state").mkdir(parents=True) - (drive_root / "state" / "state.json").write_text("{}", encoding="utf-8") - (drive_root / "memory").mkdir(parents=True) - - eq = queue.Queue() - from ouroboros.consciousness import BackgroundConsciousness - - # A single shared mock logger so all getLogger(name) calls return the same object. - # This avoids mutating any real logger singleton. - mock_logger = MagicMock() - - with patch.object(BackgroundConsciousness, "_build_registry", return_value=MagicMock()), \ - patch("ouroboros.consciousness.build_memory_sections", return_value=[]), \ - patch("ouroboros.consciousness.build_health_invariants", return_value=""), \ - patch("ouroboros.consciousness.build_runtime_section", return_value="## Runtime\n\nok"), \ - patch("ouroboros.consciousness.build_recent_sections", return_value=[]), \ - patch("logging.getLogger", return_value=mock_logger): - bc = BackgroundConsciousness( - drive_root=drive_root, - repo_dir=repo_dir, - event_queue=eq, - owner_chat_id_fn=lambda: 1, - ) - context = bc._build_context() + with caplog.at_level(logging.WARNING, logger="ouroboros.context"): + context = _wake_context(tmp_path) # 1. ARCHITECTURE.md section must be absent from context (file doesn't exist) assert "## ARCHITECTURE.md" not in context, ( "ARCHITECTURE.md section should not appear when file is missing" ) - # 2. mock_logger.warning must have been called at least once with ARCHITECTURE.md in the message - warning_messages = [ - str(call_args) - for call_args in mock_logger.warning.call_args_list - ] - arch_warnings = [w for w in warning_messages if "ARCHITECTURE.md" in w] + # 2. a warning naming ARCHITECTURE.md must have been logged + arch_warnings = [record.getMessage() for record in caplog.records + if record.levelno >= logging.WARNING and "ARCHITECTURE.md" in record.getMessage()] assert arch_warnings, ( - "BackgroundConsciousness._build_context() must call logger.warning with 'ARCHITECTURE.md' " - "when the file is missing. Core Governance Artifacts invariant in DEVELOPMENT.md. " - f"All warning calls: {warning_messages}" + "the Main context builder must log a warning naming 'ARCHITECTURE.md' when the file " + "is missing. Core Governance Artifacts invariant in DEVELOPMENT.md. " + f"All records: {[r.getMessage() for r in caplog.records]}" ) - assert any("not found" in w or "empty" in w for w in arch_warnings), ( - f"Warning message must indicate the file is missing/empty, got: {arch_warnings}" + assert any("not found" in w or "empty" in w or "unavailable" in w for w in arch_warnings), ( + f"Warning message must indicate the file is missing/unavailable, got: {arch_warnings}" ) - - def test_development_md_contains_core_governance_invariant(): """docs/DEVELOPMENT.md must contain the core governance artifact invariant rule.""" import pathlib diff --git a/tests/test_server_shutdown.py b/tests/test_server_shutdown.py index 0de5fcbf6..d487a24f4 100644 --- a/tests/test_server_shutdown.py +++ b/tests/test_server_shutdown.py @@ -637,6 +637,12 @@ def _supervisor_harness(monkeypatch, tmp_path, steps): def stop(self): pass + def tick(self, now=None): + pass + + def notify(self, reason): + pass + import time as time_mod noop = lambda *_a, **_k: None # noqa: E731 diff --git a/tests/test_settings_honesty.py b/tests/test_settings_honesty.py index 14ee47452..295ba64d2 100644 --- a/tests/test_settings_honesty.py +++ b/tests/test_settings_honesty.py @@ -159,6 +159,18 @@ def test_provider_base_url_keys_apply_on_the_next_task_not_restart(monkeypatch, assert data.get("next_task_changed") is True +def test_consciousness_wake_bounds_apply_without_a_restart(monkeypatch, isolated_settings): + """The alarm clock reads OUROBOROS_BG_WAKEUP_MIN/MAX through the config getters at each + decision (consciousness.tick / set_next_wakeup), like the other consciousness keys, so + a save never claims a restart for them (Background Consciousness redesign P2).""" + from ouroboros.settings_scales import RESTART_REQUIRED_SETTINGS + + assert not {"OUROBOROS_BG_WAKEUP_MIN", "OUROBOROS_BG_WAKEUP_MAX"} & RESTART_REQUIRED_SETTINGS + data = _save(monkeypatch, isolated_settings, {"OUROBOROS_BG_WAKEUP_MIN": "1200", "OUROBOROS_BG_WAKEUP_MAX": "7200"}) + assert not data.get("restart_required") + assert not ({"OUROBOROS_BG_WAKEUP_MIN", "OUROBOROS_BG_WAKEUP_MAX"} & set(data.get("restart_keys") or [])) + + def test_host_service_port_requires_a_restart(monkeypatch, isolated_settings): """The host-service port is bound once at server startup.""" data = _save(monkeypatch, isolated_settings, {"OUROBOROS_HOST_SERVICE_PORT": "18999"}) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 7dc5594e9..bde2de5f3 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -111,7 +111,8 @@ EXPECTED_TOOLS = [ "peek_task", "discard_child_result", "override_delegation_constraint", "request_deep_self_review", "chat_history", "update_scratchpad", "send_user_message", "update_identity", "toggle_evolution", - "toggle_consciousness", "switch_model", "get_task_result", + "toggle_consciousness", + "set_next_wakeup", "switch_model", "get_task_result", "wait_task", "wait_tasks", "tree_note", "tree_read", "delegate_start", "delegate_wait", "delegate_cancel", "delegate_answer", "read_file", "list_files", "write_file", "edit_text", diff --git a/tests/test_transport_death_retry.py b/tests/test_transport_death_retry.py index 2a1f34c8b..930000a15 100644 --- a/tests/test_transport_death_retry.py +++ b/tests/test_transport_death_retry.py @@ -15,7 +15,6 @@ from __future__ import annotations import json import queue from types import SimpleNamespace -from unittest.mock import MagicMock, patch import httpx import pytest @@ -675,58 +674,6 @@ def test_default_budget_is_zero_for_every_direct_caller(tmp_path, no_sleep): assert llm.calls == 1 -def test_background_consciousness_never_enters_the_paid_repeat_rail(tmp_path): - """Owner decision: Background Consciousness gets zero paid transport repeats. - Behavioral contract through one real cycle (`_think_scoped` with the real - `chat_observed`): a client whose send dies with a typed transport death - (httpx ReadError via `__cause__`, capture dispatched) is sent exactly ONCE — - no repeat, no round record in any durable row — and the cycle takes its own - failure path: one `consciousness_llm_error` receipt, idle reason - `llm_error`, acknowledgement withheld, wake-up backoff doubled.""" - from ouroboros.consciousness import BackgroundConsciousness - - drive_root = tmp_path / "drive" - (drive_root / "logs").mkdir(parents=True) - repo_dir = tmp_path / "repo" - repo_dir.mkdir() - with patch.object(BackgroundConsciousness, "_build_registry", return_value=MagicMock()): - bc = BackgroundConsciousness( - drive_root=drive_root, repo_dir=repo_dir, event_queue=None, owner_chat_id_fn=lambda: None, - ) - - class _DyingClient: - calls = 0 - - def _resolve_remote_target(self, _model): - return {"provider": "openrouter"} # Real route shape; local sizing makes no provider call. - - def chat(self, **_kwargs): - self.calls += 1 - raise _death(state="dispatched") - - client = _DyingClient() - bc._llm = client - wakeup_before = bc._next_wakeup_sec - with ( - patch.object(bc, "_build_context", return_value="context"), - patch.object(bc, "_tool_schemas", return_value=[]), - patch.object(bc, "_check_budget", return_value=True), - ): - assert bc._think_scoped() is False - - assert client.calls == 1 - events_text = (drive_root / "logs" / "events.jsonl").read_text() - rows = [json.loads(line) for line in events_text.splitlines() if line.strip()] - kinds = [row.get("type") for row in rows] - assert kinds.count("consciousness_llm_error") == 1 - assert "Connection error." in next(row for row in rows if row.get("type") == "consciousness_llm_error")["error"] - assert not {"llm_api_error", "llm_non_retryable_same_request", "consciousness_thought"} & set(kinds) - assert TRANSPORT_DEATHS_KEY not in events_text - assert bc._last_idle_reason == "llm_error" - assert bc._cycle_ack_allowed is False - assert bc._next_wakeup_sec == min(wakeup_before * 2, bc._wakeup_max) - - def test_classifier_and_review_custody_are_unchanged_by_the_rail(): """The global classifier still says no-resend for a dispatched death, and review custody (which consults it) keeps refusing a second paid send."""