oc_chat
Loading...
Searching...
No Matches
presenter_events.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/presenter_events.py
4
5"""
6Bus → presenter bridge for the approval and sub-agent lanes — plan 188 T2.
7
8Purpose
9-------
10``run_turn`` and ``run_sub_loop`` already publish ``tool_call_proposed``,
11``permission_decision``, ``subagent_started`` and ``subagent_completed``
12on the M2 bus, and the NDJSON :class:`~oc_chat.core.event_writer.EventWriter`
13already persists them — but nothing ever put them on screen. The
14plan→approve→act lane the substrate models (synthesis §1) was invisible,
15and a sub-agent's *completion* reached only ``_dbg``.
16
17This module is the read side for the terminal: one subscriber that folds
18those kinds into :meth:`~oc_ui.presenter_base.Presenter.show_activity`
19calls. It is the direct analogue of
20:mod:`oc_chat.core.event_bootstrap` — same one-per-process, idempotent,
21attach-at-session-bootstrap shape — and it is deliberately a *subscriber*
22rather than call-site rendering:
23
24- the approval and sub-agent lanes exist ONLY as events (the loop's own
25 call sites hand the presenter nothing about them), so the bus is where
26 the information actually is;
27- it keeps the ReAct loop free of another presenter branch;
28- "a dashboard is just another subscriber" is the banked architecture —
29 this is that same seam used for the terminal, which is exactly the
30 reuse Phase 1's projection/reducer will inherit.
31
32**Nothing here publishes.** The bus stays observability, not control
33(synthesis §2), and no event kind or payload changes.
34
35Responsibilities
36----------------
37- :class:`PresenterEventBridge` — the subscriber: event → activity line,
38 plus per-turn counters exposed via :meth:`PresenterEventBridge.snapshot`.
39- :func:`attach_presenter_bridge` — idempotently subscribe one bridge.
40- :func:`detach_presenter_bridge` — unsubscribe and clear the singleton.
41
42Diagnostics
43-----------
44Domain: OC_CHAT.MAIN
45Levels:
46 L2 — attach / detach lifecycle.
47 L3 — per-event rendering decisions.
48 L4 — module-load coverage marker.
49
50Contracts
51---------
52- ``attach_presenter_bridge`` is idempotent: a second call returns the
53 same bridge and never double-subscribes (which would double-render).
54- A presenter without ``show_activity`` (one predating plan 188) is
55 tolerated: the bridge checks and skips. The bus already isolates
56 subscriber faults, but a renderer must not rely on that.
57- ``tool_call_proposed`` is COUNTED, never rendered: ``show_tool_call``
58 already announces every invocation, so a second inline line per
59 proposal would be pure duplication. The count is still tracked so the
60 proposed-vs-executed delta is available to
61 :meth:`PresenterEventBridge.snapshot` (and to Phase 1's reducer).
62- ``permission_decision`` carries no tool linkage in the M2 envelope, so
63 the rendered line names the decision and the decider ONLY. Guessing
64 which proposal it belongs to would be a false attribution; adding a
65 linkage field would be a Phase-1 substrate change.
66
67Dependencies
68------------
69- oc_diagnostics (structured debug logging via _dbg)
70- oc_chat.core.event_bus (EventBus / default_bus — the subscription seam)
71- oc_chat.core.events (lazy; TurnEvent type hint for the event handlers)
72- oc_ui.presenter_base (lazy; Presenter type hint for the rendering target)
73"""
74from __future__ import annotations
75
76from typing import TYPE_CHECKING, Optional
77
78from oc_diagnostics import _dbg
79
80from oc_chat.core.event_bus import EventBus, Subscription, default_bus
81
82if TYPE_CHECKING:
83 from oc_chat.core.events import TurnEvent
84 from oc_ui.presenter_base import Presenter
85
86_dbg("OC_CHAT.MAIN", "presenter_events", "module", 4)
87
88
89#: Sub-agent outcomes that are not a clean finish — rendered as a warning.
90_BAD_OUTCOMES: frozenset[str] = frozenset({"error", "cancelled", "cap_hit"})
91
92#: How much of a sub-agent session id is worth showing (it is a UUID4).
93_ID_PREFIX = 8
94
95
97 """Renders the approval + sub-agent lanes onto a presenter.
98
99 Construct via :func:`attach_presenter_bridge`; the bridge owns its
100 :class:`~oc_chat.core.event_bus.Subscription` handles and releases
101 them in :meth:`detach`.
102 """
103
104 def __init__(self, presenter: "Presenter") -> None:
105 self._presenter = presenter
106 self._subs: list[Subscription] = []
107 self._proposed = 0
108 self._allowed = 0
109 self._denied = 0
112
113 # ── Wiring ────────────────────────────────────────────────────────────
114
115 def attach(self, bus: EventBus) -> None:
116 """Subscribe to the four kinds this bridge renders (or counts)."""
117 func = "attach"
118 self._subs = [
119 bus.subscribe("tool_call_proposed", self._on_proposed),
120 bus.subscribe("permission_decision", self._on_decision),
121 bus.subscribe("subagent_started", self._on_subagent_started),
122 bus.subscribe("subagent_completed", self._on_subagent_completed),
123 ]
124 self._bus = bus
125 _dbg("OC_CHAT.MAIN", func, f"{len(self._subs)} subscription(s)", 2)
126
127 def detach(self) -> None:
128 """Release every subscription. Safe to call twice."""
129 func = "detach"
130 bus = getattr(self, "_bus", None)
131 if bus is not None:
132 for sub in self._subs:
133 bus.unsubscribe(sub)
134 self._subs = []
135 _dbg("OC_CHAT.MAIN", func, "detached", 2)
136
137 # ── Rendering ─────────────────────────────────────────────────────────
138
139 def _emit(self, label: str, detail: str, tone: str) -> None:
140 """Call ``show_activity`` if the presenter has it; skip otherwise."""
141 show = getattr(self._presenter, "show_activity", None)
142 if not callable(show):
143 return
144 show(label, detail, tone=tone)
145
146 def _on_proposed(self, event: "TurnEvent") -> None:
147 """Count a proposal. Deliberately renders nothing — see Contracts."""
148 self._proposed += 1
149
150 def _on_decision(self, event: "TurnEvent") -> None:
151 """The pending-approval lane: how a gated call was resolved."""
152 payload = event.payload
153 decision = str(getattr(payload, "decision", "") or "?")
154 decided_by = str(getattr(payload, "decided_by", "") or "?")
155 if decision == "allow":
156 self._allowed += 1
157 tone = "ok"
158 else:
159 self._denied += 1
160 tone = "warn"
161 # Plan 196 follow-up: M8.1 WU-0a emits a pre-dispatch permission_decision
162 # for EVERY tool call, so rendering each one puts an approval line under
163 # every call. Routine auto-allows are still COUNTED (the snapshot stays
164 # complete) but not shown — this lane exists to surface decisions a human
165 # or a policy actually arbitrated. Decisions with no authorization_mode
166 # (pre-M8.1 shape, and the post-dispatch relay rows) render as before.
167 if str(getattr(payload, "authorization_mode", "") or "") == "auto_allow":
168 return
169 self._emit("approval", f"{decision} · decided by {decided_by}", tone)
170
171 def _on_subagent_started(self, event: "TurnEvent") -> None:
172 self._subagents_started += 1
173 sid = str(getattr(event.payload, "subagent_session_id", "") or "")
174 self._emit("sub-agent", f"started · {sid[:_ID_PREFIX]}", "info")
175
176 def _on_subagent_completed(self, event: "TurnEvent") -> None:
177 self._subagents_completed += 1
178 payload = event.payload
179 outcome = str(getattr(payload, "outcome", "") or "?")
180 sid = str(getattr(payload, "subagent_session_id", "") or "")
181 tone = "warn" if outcome in _BAD_OUTCOMES else "ok"
182 self._emit("sub-agent", f"{outcome} · {sid[:_ID_PREFIX]}", tone)
183
184 # ── Read side ─────────────────────────────────────────────────────────
185
186 def snapshot(self) -> dict:
187 """Cumulative counters for the lanes this bridge observes.
188
189 The proposed-vs-decided delta is the pending-approval view Phase 1's
190 projection/reducer will formalise; here it is a plain counter so the
191 information is at least available.
192 """
193 return {
194 "proposed": self._proposed,
195 "allowed": self._allowed,
196 "denied": self._denied,
197 "subagents_started": self._subagents_started,
198 "subagents_completed": self._subagents_completed,
199 }
200
201
202# Module-level singleton guarding re-attach attempts — the same idempotency
203# shape as event_bootstrap's ``_active_writer``. Two bridges on one bus would
204# render every line twice.
205_active_bridge: Optional[PresenterEventBridge] = None
206
207
209 presenter: "Presenter",
210 *,
211 bus: Optional[EventBus] = None,
212) -> PresenterEventBridge:
213 """Attach a singleton :class:`PresenterEventBridge` (idempotent).
214
215 Returns the active bridge. A second call returns the SAME instance
216 without re-subscribing, so the rendered line count stays at one per
217 event. *bus* defaults to the process-wide
218 :func:`~oc_chat.core.event_bus.default_bus`; tests inject their own.
219 """
220 func = "attach_presenter_bridge"
221 global _active_bridge
222 if _active_bridge is not None:
223 return _active_bridge
224 bridge = PresenterEventBridge(presenter)
225 bridge.attach(bus if bus is not None else default_bus())
226 _active_bridge = bridge
227 _dbg("OC_CHAT.MAIN", func, "presenter bridge attached", 2)
228 return bridge
229
230
232 """Unsubscribe the active bridge and clear the singleton. No-op when unattached."""
233 func = "detach_presenter_bridge"
234 global _active_bridge
235 if _active_bridge is not None:
236 _active_bridge.detach()
237 _active_bridge = None
238 _dbg("OC_CHAT.MAIN", func, "presenter bridge detached", 2)
239
240
241__all__ = [
242 "PresenterEventBridge",
243 "attach_presenter_bridge",
244 "detach_presenter_bridge",
245]
None _emit(self, str label, str detail, str tone)
PresenterEventBridge attach_presenter_bridge("Presenter" presenter, *, Optional[EventBus] bus=None)