diff --git a/ouroboros/agent.py b/ouroboros/agent.py index 8ad33fe10..e3eada038 100644 --- a/ouroboros/agent.py +++ b/ouroboros/agent.py @@ -1088,9 +1088,18 @@ class OuroborosAgent: self._current_task_type = None def _emit_progress(self, text: str, *, incident: Optional[Dict[str, str]] = None, - executor_observation: Optional[Dict[str, Any]] = None) -> None: + executor_observation: Optional[Dict[str, Any]] = None, + narration: bool = False) -> None: """Owner-visible note; ``incident`` is the typed ``task_incident``/``toast_once`` - pair the browser toasts once.""" + pair the browser toasts once. + + ``narration`` is the VOICE of the note, not its text: only the model's own + round narration (``loop_messages._emit_round_progress``) is the turn's + speech. Every other caller — checkpoints, fallback and plan notes, the + acceptance, nudge and transport lines, and the whole ToolContext ABI + (``emit_progress_fn``) — is the HOST talking about the turn, so it keeps + the default. Both voices stay visible rows; the flag decides only whether + a note may claim the card title and the collapsed activity line.""" self._last_progress_ts = time.time() if self._event_queue is None or self._current_chat_id is None: return @@ -1113,8 +1122,11 @@ class OuroborosAgent: ) if observation: progress_meta["executor_observation"] = observation - if progress_meta: - event["progress_meta"] = progress_meta + # Stamped on EVERY frame, never inferred from the absence of other + # metadata: a reader that sees no key is reading an older worker or a + # row written before the fact existed, and keeps the legacy reading. + progress_meta["narration"] = bool(narration) + event["progress_meta"] = progress_meta self._event_queue.put(event) except Exception: log.warning("Failed to emit progress event", exc_info=True) diff --git a/ouroboros/gateway/contracts.py b/ouroboros/gateway/contracts.py index 85975c628..a5439b6f9 100644 --- a/ouroboros/gateway/contracts.py +++ b/ouroboros/gateway/contracts.py @@ -191,13 +191,13 @@ class ChatOutbound(TypedDict): task_group_id: NotRequired[str] task_event: NotRequired[str] status: NotRequired[str] - # v6.82 (P5): host-attested marker, stamped by the supervisor's delivery seam ONLY - # for a task POST /api/tasks/{id}/cancel will actually stop — a lineage-resolved - # pooled ROOT (its RUNNING row) or the live in-process direct-chat turn (resolved - # through the same ownership reader the endpoint uses, supervisor.workers.direct_chat_turn); - # never a subagent frame, never an ephemeral decision turn. Gates the UI "Cancel run" action. + # v6.82 (P5): host-attested marker, stamped by the supervisor's delivery seam ONLY for a task POST /api/tasks/{id}/cancel + # will actually stop — a lineage-resolved pooled ROOT (its RUNNING row) or the live in-process direct-chat turn (resolved + # through the same ownership reader the endpoint uses, supervisor.workers.direct_chat_turn); never a subagent frame, never + # an ephemeral decision turn. Gates the UI "Cancel run" action. cancelable: NotRequired[bool] _is_direct_chat: NotRequired[bool] # lane fact stamped on a direct turn's own frames + narration: NotRequired[bool] # progress VOICE: the model's own round narration (true) vs a host note (false); absent = legacy initiator: NotRequired[str] # origin label: "consciousness" on a wake-up's frames/rows (and its roots); absent on an owner's turn # Monetary projections are nullable when the physical-attempt ledger cannot # be read. ``None`` is deliberately distinct from a confirmed $0 result. diff --git a/ouroboros/gateway/history.py b/ouroboros/gateway/history.py index 14253840f..e7ec2fb41 100644 --- a/ouroboros/gateway/history.py +++ b/ouroboros/gateway/history.py @@ -100,6 +100,9 @@ _PROGRESS_META_FIELDS = ( # the pointer on reload while its outer task_id stays empty. "lifecycle_pointer", "initiator", # the turn's origin label (a consciousness wake-up) + # The frame's voice: a replayed host note must stay a host note, or a reload + # would hand the card title back to the very line live rendering refused it. + "narration", ) _SKILL_REVIEW_STRING_FIELDS = ( diff --git a/ouroboros/loop_messages.py b/ouroboros/loop_messages.py index c0d66918a..9ee265897 100644 --- a/ouroboros/loop_messages.py +++ b/ouroboros/loop_messages.py @@ -371,13 +371,17 @@ def _emit_round_progress(content: Any, msg: Dict[str, Any], emit_progress, llm_t Visible text is retained in ``reasoning_notes``. Provider reasoning stays display-only; the native message and transcript remain unchanged. + + Both emissions are the turn's OWN speech, so both carry ``narration=True``: + this function is the single producer of model narration, and the card takes + its title and collapsed activity line from that voice alone. """ visible_text = _visible_round_text(content) if visible_text: safe_text = sanitize_tool_result_for_log(visible_text) - emit_progress(safe_text) + emit_progress(safe_text, narration=True) llm_trace["reasoning_notes"].append(safe_text) elif str(runtime_setting("OUROBOROS_REASONING_SUMMARY", "auto")).strip().lower() != "off": display_reasoning = LLMClient.extract_display_reasoning(msg) if display_reasoning: - emit_progress(sanitize_tool_result_for_log(display_reasoning)) + emit_progress(sanitize_tool_result_for_log(display_reasoning), narration=True) diff --git a/tests/test_narration_display.py b/tests/test_narration_display.py index 48e81ee41..20cb57d5c 100644 --- a/tests/test_narration_display.py +++ b/tests/test_narration_display.py @@ -5,6 +5,17 @@ touching the transcript or the round-trip-sensitive metadata.""" from ouroboros.llm import LLMClient +def _recorder(sink): + """A progress sink shaped like the real emitter: ``_emit_round_progress`` + names the round's voice with ``narration=True``, so a bare ``list.append`` + would only prove the fake's signature.""" + def emit(text, **meta): + sink.append(text) + emit.meta.append(meta) + emit.meta = [] + return emit + + def test_flat_reasoning_string(): assert LLMClient.extract_display_reasoning({"reasoning": " thinking about X "}) == "thinking about X" @@ -81,11 +92,15 @@ def test_round_progress_redacts_secret_shaped_model_text_before_trace_and_emit() progress = [] trace = {"reasoning_notes": []} - _emit_round_progress(visible, {}, progress.append, trace) + emit = _recorder(progress) + _emit_round_progress(visible, {}, emit, trace) assert candidate not in progress[0] assert candidate not in trace["reasoning_notes"][0] assert "***REDACTED***" in progress[0] + # The round's own text is the turn's voice, so the card may take its title + # from it; a redaction never demotes the line to a host note. + assert emit.meta == [{"narration": True}] def test_final_text_response_redacts_before_delivery_and_trace(): @@ -117,7 +132,7 @@ def test_round_and_final_prose_redact_all_observability_secret_classes(): trace = {"reasoning_notes": []} content = f"Credential evidence: {candidate}" - _emit_round_progress(content, {}, progress.append, trace) + _emit_round_progress(content, {}, _recorder(progress), trace) delivered, _, final_trace = _handle_text_response(content, {"reasoning_notes": []}, {}) assert candidate not in progress[0] @@ -128,7 +143,7 @@ def test_round_and_final_prose_redact_all_observability_secret_classes(): ordinary = "Edited tool.py and completed a fresh review." progress = [] trace = {"reasoning_notes": []} - _emit_round_progress(ordinary, {}, progress.append, trace) + _emit_round_progress(ordinary, {}, _recorder(progress), trace) delivered, _, final_trace = _handle_text_response(ordinary, {"reasoning_notes": []}, {}) assert progress == [ordinary] assert trace["reasoning_notes"] == [ordinary] diff --git a/tests/test_progress_narration_voice.py b/tests/test_progress_narration_voice.py new file mode 100644 index 000000000..75bc5e944 --- /dev/null +++ b/tests/test_progress_narration_voice.py @@ -0,0 +1,141 @@ +"""The VOICE of a progress note survives the producer, delivery and history seams. + +Host-authored notes (checkpoints, fallback, plan, acceptance, nudge, transport, +density) and the model's own round narration share one frame type, so the card +cannot tell them apart by text without string matching (BIBLE P5). The worker +stamps ``progress_meta.narration`` on every note it emits instead, and this +module pins that the fact is explicit at the producer, rides the live frame at +the TOP level, and replays through the progress-meta whitelist. +""" + +import asyncio +import json +import queue +from functools import partial +from types import SimpleNamespace + +import pytest + +from ouroboros.agent import OuroborosAgent +from ouroboros.gateway.history import make_chat_history_endpoint +from supervisor import events_chat_delivery, message_bus + + +def _agent(): + events = queue.Queue() + agent = SimpleNamespace( + _last_progress_ts=None, _event_queue=events, _current_chat_id=1, + _current_task_id="task-1", tools=SimpleNamespace(_ctx=SimpleNamespace(task_attempt=0)), + _subagent_progress_meta=lambda event: {}, + ) + return agent, events + + +def _emit(agent, text, **kwargs): + OuroborosAgent._emit_progress(agent, text, **kwargs) + + +def test_every_emitted_note_declares_its_voice_explicitly(): + """Absence must never be how a host note is recognised: the default is an + explicit False, so a reader can tell "host note" from "older worker".""" + agent, events = _agent() + _emit(agent, "Checkpoint 3 at round 12") + _emit(agent, "Thinking about the next step", narration=True) + _emit(agent, "⚡ Fallback: switching model lane", + incident={"task_incident": "model_lane_switch", "toast_once": "lane"}) + + host, narration, fallback = (events.get_nowait() for _ in range(3)) + assert host["progress_meta"]["narration"] is False + assert narration["progress_meta"]["narration"] is True + # An incident note is still the host talking; the typed toast pair is untouched. + assert fallback["progress_meta"]["narration"] is False + assert fallback["progress_meta"]["task_incident"] == "model_lane_switch" + assert fallback["progress_meta"]["toast_once"] == "lane" + + +def test_the_tool_context_abi_stays_a_host_voice(): + """``ctx.emit_progress_fn`` takes a single positional argument, so every tool + note keeps the default without the ABI having to know the fact exists.""" + agent, events = _agent() + ctx = SimpleNamespace(emit_progress_fn=partial(OuroborosAgent._emit_progress, agent)) + ctx.emit_progress_fn("📐 plan_task: wave 1 dispatched") + assert events.get_nowait()["progress_meta"]["narration"] is False + + +@pytest.mark.parametrize("content, msg, expected", [ + ("The answer is 42.", {}, "The answer is 42."), + ([{"type": "thinking", "thinking": "x"}], {"reasoning": "weighing the options"}, + "weighing the options"), +]) +def test_round_progress_is_the_only_narration_producer(content, msg, expected): + """Both of its emissions — visible round text and display reasoning — are the + turn's own speech.""" + from ouroboros.loop import _emit_round_progress + + seen = [] + + def emit(text, **meta): + seen.append((text, meta)) + + _emit_round_progress(content, msg, emit, {"reasoning_notes": []}) + assert seen == [(expected, {"narration": True})] + + +def test_voice_rides_the_live_frame_the_stored_row_and_the_replay(tmp_path, monkeypatch): + """Producer -> supervisor delivery -> live WS frame / progress.jsonl / history. + + The browser reads the key at the TOP level of the frame (the delivery seam + spreads progress_meta there, exactly as it does for cancelable), and a reload + must not hand the title back to a note live rendering refused it. + """ + agent, events = _agent() + _emit(agent, "Checkpoint 3 at round 12") + _emit(agent, "Reading the failing test first.", narration=True) + frames = [events.get_nowait() for _ in range(2)] + + (tmp_path / "logs").mkdir() + (tmp_path / "logs" / "chat.jsonl").touch() + live = [] + bridge = message_bus.LocalChatBridge() + bridge._broadcast_fn = live.append + monkeypatch.setattr(message_bus, "DATA_DIR", tmp_path) + monkeypatch.setattr(message_bus, "load_state", lambda: {"owner_id": 1}) + monkeypatch.setattr(message_bus, "_BRIDGE", bridge) + monkeypatch.setattr(message_bus, "publish_event", lambda *_: None) + monkeypatch.setattr(events_chat_delivery, "_bound_project_chat_id", lambda *_: 0) + delivery = SimpleNamespace( + DRIVE_ROOT=tmp_path, RUNNING={"task-1": {"task": {"id": "task-1", "_attempt": 0}}}, + send_with_budget=message_bus.send_with_budget, + append_jsonl=lambda *_: pytest.fail("delivery raised"), + ) + for frame in frames: + events_chat_delivery._handle_send_message(frame, delivery) + + stored = [json.loads(line) for line + in (tmp_path / "logs" / "progress.jsonl").read_text(encoding="utf-8").splitlines()] + response = asyncio.run(make_chat_history_endpoint(tmp_path)( + SimpleNamespace(query_params={"limit": "10"}))) + replay = [row for row in json.loads(response.body)["messages"] if row.get("is_progress")] + + for rows in (live, stored, replay): + assert [row["narration"] for row in rows] == [False, True], rows + # The voice is presentation only: the host note keeps its liveness semantics + # (that marker is supervisor-authored HOST_NARRATION, a different key). + assert all(events_chat_delivery.HOST_NARRATION not in row for row in live) + + +def test_a_stored_row_without_the_key_replays_as_a_legacy_frame(tmp_path): + """An older worker's row carries no voice; history must not invent one, so the + browser can keep promoting it exactly as it did before the fact existed.""" + logs = tmp_path / "logs" + logs.mkdir() + (logs / "chat.jsonl").touch() + (logs / "progress.jsonl").write_text(json.dumps({ + "ts": "2026-09-16T00:00:00Z", "task_id": "task-1", "content": "Working on it.", + "is_progress": True, "direction": "out", "chat_id": 1, + }) + "\n", encoding="utf-8") + response = asyncio.run(make_chat_history_endpoint(tmp_path)( + SimpleNamespace(query_params={"limit": "10"}))) + row, = json.loads(response.body)["messages"] + assert row["text"] == "Working on it." + assert "narration" not in row diff --git a/web/modules/api_types.js b/web/modules/api_types.js index e67a652f9..a757ff983 100644 --- a/web/modules/api_types.js +++ b/web/modules/api_types.js @@ -358,6 +358,14 @@ * The lane fact of a direct conversation turn, stamped by the host on the * turn's own progress/tool frames and on every task_done; the chat block * reads it before any census lists the turn. + * @property {boolean=} narration + * The VOICE of a progress frame, stamped by the worker on every note it + * emits: true only for the model's own round narration, false for every + * host-authored note (checkpoints, fallback, plan, acceptance, nudge, + * transport, density). Both stay visible rows; only narration may claim the + * card title and the collapsed activity line. Absent = a frame that predates + * the fact (an older worker, a supervisor note, a stored row), which keeps + * the legacy reading that promoted every progress frame. * @property {string=} initiator * The turn's origin label: "consciousness" on every frame and row of a * self-initiated wake-up (and the roots it starts); absent on an owner's turn.