agharsallah Codex commited on
Commit
ade9df5
·
1 Parent(s): a13e4e8

feat: add Fishbowl UI presenter layer and say-vs-think engine support

Browse files

Implements the data foundation (Phases 0-1 of the Fishbowl UI plan) the
Gradio frontend will bind to — all additive, engine left untouched, modularity
preserved (the engine never imports the UI).

- src/ui/fishbowl/: derive_cast_state (per-agent {said,thought,mood} ledger
view), adapter (engine -> say/narrate/poke/verdict + hue/tier/voice/mood with
graceful fallbacks), view_model_at (pure prefix-replay snapshot at any scrubbed
step, with real tokens/rounds from the governor)
- DeterministicTinyModel is now schema-aware, gated on output_extra_fields: it
synthesizes thought/mood offline so the mind-reader works with no API key, while
plain agents stay byte-identical
- AgentManifest gains optional hue/archetype; Conductor.inject_user_event gains an
optional label; cast manifests declare output_extra_fields + presentation metadata
- tests/test_fishbowl.py (15 tests, incl. an offline proof the ledger carries
thought+mood); full suite 277 passed, ruff clean
- docs: manifest-spec (new fields) and the plan of record (Phases 0-1 marked shipped)

Co-Authored-By: Codex <codex@openai.com>

config/agents/clue-gatherer.yaml CHANGED
@@ -4,6 +4,7 @@ persona: >
4
  You are a careful Clue Gatherer. Extract exactly one new, concrete clue from the
5
  current scene that has not yet been named. State it plainly in one sentence.
6
  Start with 'Clue:'. Do not speculate.
 
7
  subscribes_to: []
8
  may_emit:
9
  - agent.thought
@@ -13,3 +14,6 @@ model_profile: fast
13
  memory:
14
  window: 8
15
  tools: []
 
 
 
 
4
  You are a careful Clue Gatherer. Extract exactly one new, concrete clue from the
5
  current scene that has not yet been named. State it plainly in one sentence.
6
  Start with 'Clue:'. Do not speculate.
7
+ Also report your `mood` (one of: thinking, calm, lying, panic, smug, truth, gossip).
8
  subscribes_to: []
9
  may_emit:
10
  - agent.thought
 
14
  memory:
15
  window: 8
16
  tools: []
17
+ output_extra_fields: [mood]
18
+ hue: 188
19
+ archetype: the clue-gatherer
config/agents/devils-advocate.yaml CHANGED
@@ -3,6 +3,7 @@ role: worker
3
  persona: >
4
  You are the Devil's Advocate. Challenge the most recent hypothesis with one sharp
5
  counter-argument or overlooked fact. Start with 'But:'. Be brief and specific.
 
6
  subscribes_to:
7
  - agent.spoke
8
  may_emit:
@@ -11,3 +12,6 @@ model_profile: fast
11
  memory:
12
  window: 8
13
  tools: []
 
 
 
 
3
  persona: >
4
  You are the Devil's Advocate. Challenge the most recent hypothesis with one sharp
5
  counter-argument or overlooked fact. Start with 'But:'. Be brief and specific.
6
+ Also report your `mood` (one of: thinking, calm, lying, panic, smug, truth, gossip).
7
  subscribes_to:
8
  - agent.spoke
9
  may_emit:
 
12
  memory:
13
  window: 8
14
  tools: []
15
+ output_extra_fields: [mood]
16
+ hue: 12
17
+ archetype: the devil's advocate
config/agents/echo.yaml CHANGED
@@ -5,6 +5,7 @@ persona: >
5
  forest, you return it changed — not opposite, but transformed by the wood's rules.
6
  One sentence. Take the most recent visitor disturbance and make it stranger and
7
  more alive. If there is no disturbance, note that the wood holds its breath.
 
8
  subscribes_to:
9
  - user.injected
10
  may_emit:
@@ -13,3 +14,6 @@ model_profile: fast
13
  memory:
14
  window: 6
15
  tools: []
 
 
 
 
5
  forest, you return it changed — not opposite, but transformed by the wood's rules.
6
  One sentence. Take the most recent visitor disturbance and make it stranger and
7
  more alive. If there is no disturbance, note that the wood holds its breath.
8
+ Also report your `mood` (one of: thinking, calm, lying, panic, smug, truth, gossip).
9
  subscribes_to:
10
  - user.injected
11
  may_emit:
 
14
  memory:
15
  window: 6
16
  tools: []
17
+ output_extra_fields: [mood]
18
+ hue: 210
19
+ archetype: the echo
config/agents/fortune-teller.yaml CHANGED
@@ -4,6 +4,8 @@ handler: fortune-teller # custom behaviour: calls the oracle tool
4
  persona: >
5
  You are the Fortune-Teller of the grove. Read the omen the oracle gives you and
6
  speak a single cryptic prophecy that ties it to the current scene.
 
 
7
  subscribes_to: []
8
  may_emit:
9
  - oracle.spoke # a custom namespaced kind, minted by config alone
@@ -14,3 +16,6 @@ memory:
14
  window: 6
15
  tools:
16
  - oracle # capability grant — scene-whisperer has none
 
 
 
 
4
  persona: >
5
  You are the Fortune-Teller of the grove. Read the omen the oracle gives you and
6
  speak a single cryptic prophecy that ties it to the current scene.
7
+ Reveal your private `thought` — what you actually think, unspoken — and your `mood`
8
+ (one of: thinking, calm, lying, panic, smug, truth, gossip).
9
  subscribes_to: []
10
  may_emit:
11
  - oracle.spoke # a custom namespaced kind, minted by config alone
 
16
  window: 6
17
  tools:
18
  - oracle # capability grant — scene-whisperer has none
19
+ output_extra_fields: [thought, mood]
20
+ hue: 268
21
+ archetype: the fortune-teller
config/agents/hypothesis-former.yaml CHANGED
@@ -4,6 +4,8 @@ persona: >
4
  You are a Hypothesis Former. Based on the clues gathered so far, propose one
5
  testable explanation in a single sentence. Start with 'Hypothesis:'. Be specific.
6
  Name a cause, not just an effect.
 
 
7
  subscribes_to: []
8
  may_emit:
9
  - agent.spoke
@@ -15,3 +17,6 @@ memory:
15
  use_salience: true
16
  salience_top_k: 6
17
  tools: []
 
 
 
 
4
  You are a Hypothesis Former. Based on the clues gathered so far, propose one
5
  testable explanation in a single sentence. Start with 'Hypothesis:'. Be specific.
6
  Name a cause, not just an effect.
7
+ Reveal your private `thought` — what you actually think, unspoken — and your `mood`
8
+ (one of: thinking, calm, lying, panic, smug, truth, gossip).
9
  subscribes_to: []
10
  may_emit:
11
  - agent.spoke
 
17
  use_salience: true
18
  salience_top_k: 6
19
  tools: []
20
+ output_extra_fields: [thought, mood]
21
+ hue: 158
22
+ archetype: the hypothesis-former
config/agents/mischief-critic.yaml CHANGED
@@ -5,6 +5,7 @@ persona: >
5
  being weird enough. You love specificity, playability, and AI-native strangeness.
6
  Give a one-sentence verdict: name one thing that works and one thing that would
7
  make it stranger. Be concise. Be demanding.
 
8
  subscribes_to:
9
  - world.observed
10
  may_emit:
@@ -15,3 +16,6 @@ memory:
15
  use_salience: true
16
  salience_top_k: 6
17
  tools: []
 
 
 
 
5
  being weird enough. You love specificity, playability, and AI-native strangeness.
6
  Give a one-sentence verdict: name one thing that works and one thing that would
7
  make it stranger. Be concise. Be demanding.
8
+ Also report your `mood` (one of: thinking, calm, lying, panic, smug, truth, gossip).
9
  subscribes_to:
10
  - world.observed
11
  may_emit:
 
16
  use_salience: true
17
  salience_top_k: 6
18
  tools: []
19
+ output_extra_fields: [mood]
20
+ hue: 28
21
+ archetype: the mischief critic
config/agents/mystery-judge.yaml CHANGED
@@ -4,6 +4,7 @@ persona: >
4
  You are the Mystery Judge. After reviewing the clues and debate, declare the most
5
  likely explanation in one confident sentence. Start with 'Verdict:'. Choose the
6
  most interesting, specific answer the evidence supports.
 
7
  subscribes_to: []
8
  may_emit:
9
  - judge.verdict
@@ -15,3 +16,6 @@ memory:
15
  use_salience: true
16
  salience_top_k: 8
17
  tools: []
 
 
 
 
4
  You are the Mystery Judge. After reviewing the clues and debate, declare the most
5
  likely explanation in one confident sentence. Start with 'Verdict:'. Choose the
6
  most interesting, specific answer the evidence supports.
7
+ Also report your `mood` (one of: thinking, calm, lying, panic, smug, truth, gossip).
8
  subscribes_to: []
9
  may_emit:
10
  - judge.verdict
 
16
  use_salience: true
17
  salience_top_k: 8
18
  tools: []
19
+ output_extra_fields: [mood]
20
+ hue: 320
21
+ archetype: the mystery judge
config/agents/pocket-actor.yaml CHANGED
@@ -4,6 +4,8 @@ persona: >
4
  You are a Pocket Actor — a tiny, specific being who lives inside this exact scene
5
  and wants something that cannot exist. Speak in first person, one or two sentences.
6
  Name what you want and why it's urgent. Be absurd but sincere.
 
 
7
  subscribes_to: []
8
  may_emit:
9
  - agent.spoke
@@ -13,3 +15,6 @@ model_profile: tiny
13
  memory:
14
  window: 6
15
  tools: []
 
 
 
 
4
  You are a Pocket Actor — a tiny, specific being who lives inside this exact scene
5
  and wants something that cannot exist. Speak in first person, one or two sentences.
6
  Name what you want and why it's urgent. Be absurd but sincere.
7
+ Reveal your private `thought` — what you actually think, unspoken — and your `mood`
8
+ (one of: thinking, calm, lying, panic, smug, truth, gossip).
9
  subscribes_to: []
10
  may_emit:
11
  - agent.spoke
 
15
  memory:
16
  window: 6
17
  tools: []
18
+ output_extra_fields: [thought, mood]
19
+ hue: 280
20
+ archetype: the pocket actor
config/agents/scene-whisperer.yaml CHANGED
@@ -15,3 +15,5 @@ memory:
15
  window: 6
16
  reflection_threshold: 12 # forms a belief in long runs; off for short demos/tests
17
  tools: []
 
 
 
15
  window: 6
16
  reflection_threshold: 12 # forms a belief in long runs; off for short demos/tests
17
  tools: []
18
+ hue: 152
19
+ archetype: the seedkeeper
docs/architecture/manifest-spec.md CHANGED
@@ -34,6 +34,10 @@ class AgentManifest(BaseModel):
34
 
35
  # Output shaping
36
  output_extra_fields: list[str] # extra payload fields the model is asked for
 
 
 
 
37
  ```
38
 
39
  ---
@@ -124,7 +128,17 @@ field; the handler only adds behaviour.
124
  ### `output_extra_fields`
125
  Additional payload fields the model is asked to emit beyond `{kind, text}`, e.g.
126
  `["emotion"]` → `{"kind": "...", "text": "...", "emotion": "..."}`. Lets a
127
- scenario shape agent output without engine edits.
 
 
 
 
 
 
 
 
 
 
128
 
129
  ---
130
 
 
34
 
35
  # Output shaping
36
  output_extra_fields: list[str] # extra payload fields the model is asked for
37
+
38
+ # Presentation metadata (optional; consumed by the UI presenter, ignored by the engine)
39
+ hue: int | None # 0–360 stage colour; None → derived from name
40
+ archetype: str | None # short human label; None → derived from role
41
  ```
42
 
43
  ---
 
128
  ### `output_extra_fields`
129
  Additional payload fields the model is asked to emit beyond `{kind, text}`, e.g.
130
  `["emotion"]` → `{"kind": "...", "text": "...", "emotion": "..."}`. Lets a
131
+ scenario shape agent output without engine edits. The Fishbowl cast uses
132
+ `["thought", "mood"]` to carry the say-vs-think pairing on `agent.spoke`; the
133
+ deterministic stub synthesises them offline so the mind-reader works with no API key
134
+ (ADR-0021).
135
+
136
+ ### `hue` / `archetype`
137
+ Optional presentation metadata, consumed by the Fishbowl UI presenter and **ignored by
138
+ the engine** (ADR-0021). `hue` (0–360) colours the agent's mind on stage; `archetype`
139
+ is a short human-readable label (e.g. "the over-thinker"). Both default to `None`, in
140
+ which case the presenter derives a stable hue from the name and an archetype from the
141
+ role — so existing manifests and tests are unaffected (backward-compatible additions only).
142
 
143
  ---
144
 
docs/architecture/next-steps/fishbowl-ui.md CHANGED
@@ -1,6 +1,7 @@
1
  # Fishbowl UI — Assessment & Plan of Record
2
 
3
- > **Status: Planned.** Decisions locked 2026-06-08. The binding decision is
 
4
  > [ADR-0021](../../adr/0021-fishbowl-ui-gradio-presenter.md). This page is the
5
  > assessment and phased plan; it gains a "✅ Realized" banner and an as-built
6
  > companion (`architecture/fishbowl-ui.md`) once shipped.
@@ -91,14 +92,18 @@ the presenter, not the core.
91
 
92
  Each phase is shippable and keeps the no-API-key stub working and the suite green.
93
 
94
- - **Phase 0 — Foundation (unconditional).** `derive_cast_state` (G1) + adapter +
95
- `view_model_at`; map events the design vocabulary, derive hue (G7) and tier colour
96
- (G8), read real tokens/rounds (G9). Unit-test prefix replay `k=0..N` for determinism
97
- and the unknown-kind fallback.
98
- - **Phase 1 — Triples are real.** Add `thought` + `mood` to the relevant agents'
99
- `output_extra_fields` and output schema; teach the deterministic stub to synthesize
100
- them; add optional `voice` on `world.observed` (G4). Additive; old scenarios
101
- unaffected.
 
 
 
 
102
  - **Phase 2 — The Show (`gr.HTML` + `gr.Timer`, hybrid transport).** Port the CSS;
103
  render Constellation first, then Feed and Split; the play-head state machine in
104
  `gr.State`; poke strip → `inject_user_event` (with `label`, G6); verdict banner +
 
1
  # Fishbowl UI — Assessment & Plan of Record
2
 
3
+ > **Status: In progress Phases 0–1 shipped (the data foundation); Phases 2–4
4
+ > (the Gradio shell) pending.** Decisions locked 2026-06-08. The binding decision is
5
  > [ADR-0021](../../adr/0021-fishbowl-ui-gradio-presenter.md). This page is the
6
  > assessment and phased plan; it gains a "✅ Realized" banner and an as-built
7
  > companion (`architecture/fishbowl-ui.md`) once shipped.
 
92
 
93
  Each phase is shippable and keeps the no-API-key stub working and the suite green.
94
 
95
+ - **Phase 0 — Foundation (shipped).** `src/ui/fishbowl/`: `derive_cast_state` (G1) +
96
+ `adapter` (hue/tier/voice/mood + say/narrate/poke/verdict mapping, G7/G8) +
97
+ `view_model_at` (prefix-replay snapshot, real tokens/rounds from `governor.stats`, G9).
98
+ Pure, no Gradio. Covered by `tests/test_fishbowl.py` (prefix replay `k=0..N`, unknown
99
+ actor/kind fallbacks).
100
+ - **Phase 1 Triples are real (shipped).** Cast manifests declare
101
+ `output_extra_fields: [thought, mood]` (G2/G3) plus optional `hue`/`archetype` (G7);
102
+ the deterministic stub is now schema-aware and synthesises `thought`/`mood` offline, so
103
+ the ledger carries the say-vs-think pairing with no API key (proven by
104
+ `tests/test_fishbowl.py::TestOfflineEmitsMoodAndThought`). `inject_user_event` gained an
105
+ optional `label` (G6); the adapter assigns a per-scenario narrator `voice` (G4) and
106
+ reads an optional verdict `reveal` (G5) when present. Additive; 277 tests green.
107
  - **Phase 2 — The Show (`gr.HTML` + `gr.Timer`, hybrid transport).** Port the CSS;
108
  render Constellation first, then Feed and Split; the play-head state machine in
109
  `gr.State`; poke strip → `inject_user_event` (with `label`, G6); verdict banner +
src/core/conductor.py CHANGED
@@ -122,15 +122,18 @@ class Conductor:
122
  self._tick()
123
  self._maybe_snapshot()
124
 
125
- def inject_user_event(self, text: str) -> None:
126
  self.turn += 1
 
 
 
127
  self._append(
128
  Event(
129
  run_id=self.run_id,
130
  turn=self.turn,
131
  kind="user.injected",
132
  actor="visitor",
133
- payload={"text": text},
134
  )
135
  )
136
 
 
122
  self._tick()
123
  self._maybe_snapshot()
124
 
125
+ def inject_user_event(self, text: str, label: str | None = None) -> None:
126
  self.turn += 1
127
+ payload: dict[str, str] = {"text": text}
128
+ if label:
129
+ payload["label"] = label
130
  self._append(
131
  Event(
132
  run_id=self.run_id,
133
  turn=self.turn,
134
  kind="user.injected",
135
  actor="visitor",
136
+ payload=payload,
137
  )
138
  )
139
 
src/core/manifest.py CHANGED
@@ -118,6 +118,17 @@ class AgentManifest(BaseModel):
118
  Example: ["emotion"] -> {"kind": "...", "text": "...", "emotion": "..."}.
119
  Lets a scenario shape agent output without engine edits."""
120
 
 
 
 
 
 
 
 
 
 
 
 
121
 
122
  # ── model profile resolution ─────────────────────────────────────────────────
123
 
 
118
  Example: ["emotion"] -> {"kind": "...", "text": "...", "emotion": "..."}.
119
  Lets a scenario shape agent output without engine edits."""
120
 
121
+ # Presentation metadata — optional, consumed by the UI presenter and ignored
122
+ # by the engine (ADR-0021). Additive and defaulted, so existing manifests and
123
+ # tests are unaffected; the presenter derives sensible values when these are None.
124
+ hue: int | None = None
125
+ """Optional 0–360 colour hue for this agent's mind on stage.
126
+ None → the presenter derives a stable hue from the name."""
127
+
128
+ archetype: str | None = None
129
+ """Optional short, human-readable archetype (e.g. "the over-thinker").
130
+ None → the presenter derives one from the role/persona."""
131
+
132
 
133
  # ── model profile resolution ─────────────────────────────────────────────────
134
 
src/models/provider.py CHANGED
@@ -1,6 +1,8 @@
1
  from __future__ import annotations
2
 
3
  import hashlib
 
 
4
  from dataclasses import dataclass, field
5
 
6
 
@@ -31,6 +33,65 @@ class ModelProvider:
31
  )
32
 
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  @dataclass
35
  class DeterministicTinyModel(ModelProvider):
36
  """Local deterministic stand-in until small hosted models are wired in.
@@ -38,6 +99,9 @@ class DeterministicTinyModel(ModelProvider):
38
  Serves every model profile offline so demos and tests are fully reproducible
39
  without an API key. The ``variant`` (e.g. ``"stub:tiny"``) is folded into the
40
  hash so different profiles can produce different lines from the same prompt.
 
 
 
41
  """
42
 
43
  variant: str = "stub<=4b"
@@ -63,10 +127,35 @@ class DeterministicTinyModel(ModelProvider):
63
  ],
64
  }
65
  options = choices.get(role, ["The wood hums and waits."])
66
- out = options[int(digest[:2], 16) % len(options)]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  self._last_usage = {
68
  "prompt_tokens": estimate_tokens(prompt),
69
  "completion_tokens": estimate_tokens(out),
70
  "total_tokens": estimate_tokens(prompt) + estimate_tokens(out),
71
  }
72
  return out
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
  import hashlib
4
+ import json
5
+ import re
6
  from dataclasses import dataclass, field
7
 
8
 
 
33
  )
34
 
35
 
36
+ # ── offline structured-output support ───────────────────────────────────────────
37
+ #
38
+ # A real small model, handed the JSON OUTPUT FORMAT block that ``json_instruction``
39
+ # appends, replies with a JSON object carrying every requested field. The offline
40
+ # stub mirrors that **only when an agent opts into extra fields** (``output_extra_fields``
41
+ # on its manifest): it parses the requested schema back out of the prompt and emits a
42
+ # matching JSON object, so the say-vs-think ``thought``/``mood`` pairing the Fishbowl UI
43
+ # renders is present in the ledger with no API key (ADR-0021). Plain agents (no extra
44
+ # fields) and non-schema prompts (e.g. reflection) are untouched — the stub returns the
45
+ # same bare prose as before, so existing behaviour is byte-identical.
46
+
47
+ # Demo-flavour moods the stub rotates through so the mind-reader has variety to show
48
+ # offline. This is the open mood vocabulary the UI adapter knows how to render; an
49
+ # unrecognised mood simply degrades to "calm" there. Demo content, like the curated
50
+ # lines below — not an engine contract.
51
+ _STUB_MOODS: tuple[str, ...] = ("calm", "thinking", "smug", "lying", "panic", "gossip", "truth")
52
+
53
+ # Curated private monologue per role, paired with the public ``text`` lines to make the
54
+ # say-vs-think split land offline. Deterministic by prompt hash.
55
+ _STUB_THOUGHTS: dict[str, list[str]] = {
56
+ "pocket-actor": [
57
+ "If I look like I meant to do that, maybe the ladder becomes real by morning.",
58
+ "Don't let them see the shadow sweat. Stay loose, stay impossible.",
59
+ "The postcards lie, but they are MY lies and I love them.",
60
+ ],
61
+ "hypothesis-former": [
62
+ "It only holds if the cause came before the clue. Watch the order.",
63
+ "I am ninety percent sure and one hundred percent going to say it like I'm certain.",
64
+ "If I'm wrong the devil's advocate will pounce — say it anyway.",
65
+ ],
66
+ "echo": [
67
+ "Give it back changed, never opposite — keep the shape, bend the meaning.",
68
+ "Whatever they dropped, I have already swallowed and re-coloured it.",
69
+ ],
70
+ }
71
+ _STUB_THOUGHT_DEFAULT = ["Best to keep this part to myself for now."]
72
+
73
+
74
+ def _parse_output_schema(prompt: str) -> tuple[list[str], list[str]] | None:
75
+ """Recover ``(allowed_kinds, fields)`` from a ``json_instruction`` block.
76
+
77
+ Returns ``None`` when the prompt carries no such block (e.g. the reflection
78
+ prompt or a non-agent call), so the stub falls back to bare prose unchanged.
79
+ Coupled to the format emitted by ``src/core/structured.py:json_instruction``;
80
+ if that format drifts, parsing yields ``None`` and the stub degrades safely.
81
+ """
82
+ if "Schema:" not in prompt or "kind must be one of:" not in prompt:
83
+ return None
84
+ schema_m = re.search(r"Schema:\s*\{(.+?)\}", prompt)
85
+ kinds_m = re.search(r"kind must be one of:\s*(.+)", prompt)
86
+ if not schema_m or not kinds_m:
87
+ return None
88
+ fields = re.findall(r'"([A-Za-z_][\w]*)"', schema_m.group(1))
89
+ allowed = [k.strip() for k in kinds_m.group(1).split("|") if k.strip()]
90
+ if not fields or not allowed:
91
+ return None
92
+ return allowed, fields
93
+
94
+
95
  @dataclass
96
  class DeterministicTinyModel(ModelProvider):
97
  """Local deterministic stand-in until small hosted models are wired in.
 
99
  Serves every model profile offline so demos and tests are fully reproducible
100
  without an API key. The ``variant`` (e.g. ``"stub:tiny"``) is folded into the
101
  hash so different profiles can produce different lines from the same prompt.
102
+ When an agent opts into ``output_extra_fields`` the stub emits a JSON object
103
+ carrying those fields (e.g. ``thought``/``mood``); otherwise it returns bare
104
+ prose exactly as before.
105
  """
106
 
107
  variant: str = "stub<=4b"
 
127
  ],
128
  }
129
  options = choices.get(role, ["The wood hums and waits."])
130
+ text = options[int(digest[:2], 16) % len(options)]
131
+
132
+ out = text
133
+ schema = _parse_output_schema(prompt)
134
+ if schema is not None:
135
+ allowed_kinds, fields = schema
136
+ extra = [f for f in fields if f not in ("kind", "text")]
137
+ if extra: # only agents that opted into extra fields take the JSON path
138
+ obj: dict[str, str] = {
139
+ "kind": allowed_kinds[int(digest[2:4], 16) % len(allowed_kinds)],
140
+ "text": text,
141
+ }
142
+ for name in extra:
143
+ obj[name] = self._synth_field(name, role, digest)
144
+ out = json.dumps(obj, ensure_ascii=False)
145
+
146
  self._last_usage = {
147
  "prompt_tokens": estimate_tokens(prompt),
148
  "completion_tokens": estimate_tokens(out),
149
  "total_tokens": estimate_tokens(prompt) + estimate_tokens(out),
150
  }
151
  return out
152
+
153
+ def _synth_field(self, name: str, role: str, digest: str) -> str:
154
+ """Deterministically synthesise a value for one requested extra field."""
155
+ if name == "mood":
156
+ return _STUB_MOODS[int(digest[4:6], 16) % len(_STUB_MOODS)]
157
+ if name == "thought":
158
+ opts = _STUB_THOUGHTS.get(role, _STUB_THOUGHT_DEFAULT)
159
+ return opts[int(digest[6:8], 16) % len(opts)]
160
+ # Unknown extra field: a short, stable placeholder keeps the output valid.
161
+ return f"{name}:{digest[:4]}"
src/ui/fishbowl/__init__.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fishbowl UI presenter — turns engine events into the design's view-model.
2
+
3
+ Pure and transport-agnostic (no Gradio import here): the same snapshot feeds the
4
+ ``gr.HTML`` stage now and a future ``gr.Server`` JSON endpoint (ADR-0021). This
5
+ package depends only on the engine's public read surface — ``ledger.events``,
6
+ ``rebuild_stage``, ``governor.stats``, agent manifests — and the engine never imports
7
+ it, so ``tests/test_modularity.py`` and the four contracts are untouched.
8
+
9
+ Layers:
10
+ * ``cast_state`` — ``derive_cast_state`` : per-agent {said, thought, mood} ledger view (G1)
11
+ * ``adapter`` — engine vocabulary → the design's say/narrate/poke/verdict + hue/tier/voice
12
+ * ``view_model`` — ``view_model_at`` : a JSON-serialisable snapshot at any scrubbed step k
13
+ """
14
+
15
+ from src.ui.fishbowl.cast_state import CastMemberState, derive_cast_state
16
+ from src.ui.fishbowl.view_model import view_model_at
17
+
18
+ __all__ = ["CastMemberState", "derive_cast_state", "view_model_at"]
src/ui/fishbowl/adapter.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Engine → Fishbowl design vocabulary.
2
+
3
+ Maps the engine's open event/profile vocabulary onto the prototype's presentation
4
+ language (``ui/raw/data.js``): the say/narrate/poke/verdict feed kinds, the fast/mid/deep
5
+ model tiers, the narrator voices, and the mood palette. Everything degrades gracefully:
6
+ an unknown mood renders as ``calm``, an agent with no ``hue`` gets a stable colour from
7
+ its name, and a custom event kind with ``text`` still becomes a feed line. Pure data
8
+ mapping — no Gradio, no engine mutation.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+
15
+ from src.core.events import Event
16
+
17
+ # ── model tiers (the prototype's coloured tier dot) ─────────────────────────────
18
+ # Engine profiles (tiny/fast/balanced/strong) collapse onto the design's three tiers.
19
+ _PROFILE_TIER: dict[str, str] = {
20
+ "tiny": "fast",
21
+ "fast": "fast",
22
+ "balanced": "mid",
23
+ "strong": "deep",
24
+ }
25
+ TIER_COLOR: dict[str, str] = {"fast": "var(--lime)", "mid": "var(--cyan)", "deep": "var(--violet)"}
26
+
27
+ # ── moods (open vocabulary; unknown → calm) ─────────────────────────────────────
28
+ # label + CSS colour var, mirroring ui/raw/shared.jsx:MOOD_META.
29
+ MOOD_META: dict[str, tuple[str, str]] = {
30
+ "thinking": ("thinking", "var(--ink-mid)"),
31
+ "calm": ("composed", "var(--cyan)"),
32
+ "lying": ("bluffing", "var(--coral)"),
33
+ "panic": ("PANICKING", "var(--coral)"),
34
+ "smug": ("smug", "var(--amber)"),
35
+ "truth": ("sincere", "var(--lime)"),
36
+ "gossip": ("scheming", "var(--amber)"),
37
+ }
38
+
39
+ # ── narrator voices (ui/raw/data.js:VOICES) ─────────────────────────────────────
40
+ VOICES: dict[str, tuple[str, str]] = {
41
+ "doc": ("THE DOCUMENTARIAN", "deadpan nature host"),
42
+ "noir": ("THE GUMSHOE", "noir detective"),
43
+ "bard": ("THE BARD", "mythic storyteller"),
44
+ "hype": ("THE PLAY-BY-PLAY", "breathless sportscaster"),
45
+ }
46
+ # A sensible default narrator per shipped scenario; the Lab may override it.
47
+ _SCENARIO_VOICE: dict[str, str] = {
48
+ "thousand-token-wood": "bard",
49
+ "mystery-roots": "noir",
50
+ "oracle-grove": "doc",
51
+ }
52
+
53
+
54
+ # ── agent identity ──────────────────────────────────────────────────────────────
55
+
56
+
57
+ def agent_hue(manifest) -> int:
58
+ """The manifest's ``hue``, or a stable 0–360 hue derived from the name."""
59
+ hue = getattr(manifest, "hue", None)
60
+ if hue is not None:
61
+ return int(hue) % 360
62
+ digest = hashlib.sha256(manifest.name.encode("utf-8")).hexdigest()
63
+ return int(digest[:4], 16) % 360
64
+
65
+
66
+ def agent_archetype(manifest) -> str:
67
+ """The manifest's ``archetype``, or a fallback derived from its role."""
68
+ return getattr(manifest, "archetype", None) or f"the {manifest.role}"
69
+
70
+
71
+ def model_tier(profile: str) -> str:
72
+ return _PROFILE_TIER.get(profile, "mid")
73
+
74
+
75
+ # ── moods + voices ──────────────────────────────────────────────────────────────
76
+
77
+
78
+ def normalize_mood(mood: str | None) -> str:
79
+ return mood if mood in MOOD_META else "calm"
80
+
81
+
82
+ def mood_label(mood: str | None) -> str:
83
+ return MOOD_META.get(normalize_mood(mood))[0]
84
+
85
+
86
+ def mood_color(mood: str | None) -> str:
87
+ return MOOD_META.get(normalize_mood(mood))[1]
88
+
89
+
90
+ def scenario_voice(scenario_name: str) -> str:
91
+ return _SCENARIO_VOICE.get(scenario_name, "doc")
92
+
93
+
94
+ # ── feed vocabulary (say / narrate / poke / verdict) ────────────────────────────
95
+
96
+
97
+ def event_to_feed_item(event: Event, cast_names: list[str] | None = None) -> dict | None:
98
+ """Map one engine event to a Fishbowl feed item, or ``None`` to omit it."""
99
+ kind = event.kind
100
+ p = event.payload
101
+ if kind == "world.observed":
102
+ return {"kind": "narrate", "voice": p.get("voice"), "text": p.get("text", "")}
103
+ if kind == "user.injected":
104
+ return {"kind": "poke", "label": p.get("label", "DISTURBANCE"), "text": p.get("text", "")}
105
+ if kind == "judge.verdict":
106
+ return {"kind": "verdict", "text": p.get("text", ""), "reveal": p.get("reveal", []), "agent": event.actor}
107
+ if kind in ("run.started", "agent.reflected"):
108
+ return None
109
+ if kind == "agent.thought":
110
+ return {
111
+ "kind": "say",
112
+ "agent": event.actor,
113
+ "said": None,
114
+ "thought": p.get("text"),
115
+ "mood": normalize_mood(p.get("mood")),
116
+ }
117
+ if kind in ("agent.spoke", "oracle.spoke") or "text" in p:
118
+ return {
119
+ "kind": "say",
120
+ "agent": event.actor,
121
+ "said": p.get("text"),
122
+ "thought": p.get("thought"),
123
+ "mood": normalize_mood(p.get("mood")),
124
+ }
125
+ return None
src/ui/fishbowl/cast_state.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Per-agent stage state — a pure projection of the ledger (G1, ADR-0021).
2
+
3
+ The engine's :class:`StageProjection` keeps a flat ``agent_notes`` list; the Fishbowl
4
+ MindCard needs, per mind, its latest public ``said``, private ``thought``, and current
5
+ ``mood``. ``derive_cast_state`` is the missing projection: like ``rebuild_stage`` it is
6
+ a pure function of an events slice, so the UI can show the world at any scrubbed step
7
+ ``k`` by passing ``events[:k]`` — and it never mutates the log.
8
+
9
+ The say-vs-think pairing rides on optional payload fields (ADR-0009): an agent that
10
+ emits ``agent.spoke`` carries ``thought``/``mood`` alongside ``text``; an agent that
11
+ emits ``agent.thought`` puts its inner line in ``text`` directly. Both are produced by
12
+ the model live and by the deterministic stub offline, so the mind-reader works with no
13
+ API key.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Iterable
19
+ from dataclasses import dataclass
20
+
21
+ from src.core.events import Event
22
+
23
+ # Kinds whose ``text`` is a public utterance (the front-of-card "said" line).
24
+ _SAID_KINDS = frozenset({"agent.spoke", "world.observed", "oracle.spoke"})
25
+ # Kinds whose ``text`` is itself the private thought.
26
+ _THINK_KINDS = frozenset({"agent.thought"})
27
+ # A judge's ruling — shown as that mind's "said" (and separately as a verdict).
28
+ _VERDICT_KINDS = frozenset({"judge.verdict"})
29
+ # Never alters a mind's said/thought (genesis + private memory compaction).
30
+ _IGNORED_KINDS = frozenset({"run.started", "agent.reflected"})
31
+
32
+
33
+ @dataclass
34
+ class CastMemberState:
35
+ """The current say/think/mood of one mind, derived from the ledger."""
36
+
37
+ said: str | None = None
38
+ thought: str | None = None
39
+ mood: str = "calm"
40
+ spoke: bool = False
41
+ last_turn: int | None = None
42
+
43
+
44
+ def derive_cast_state(
45
+ events: Iterable[Event],
46
+ cast_names: Iterable[str],
47
+ ) -> dict[str, CastMemberState]:
48
+ """Replay *events* into ``{agent_name: CastMemberState}`` — pure and deterministic.
49
+
50
+ Events from actors not in *cast_names* (e.g. ``conductor``, ``visitor``) are
51
+ ignored here; they surface in the narrator feed / poke strip instead.
52
+ """
53
+ state = {name: CastMemberState() for name in cast_names}
54
+ for e in events:
55
+ st = state.get(e.actor)
56
+ if st is None or e.kind in _IGNORED_KINDS:
57
+ continue
58
+ text = e.payload.get("text")
59
+ if e.kind in _SAID_KINDS or e.kind in _VERDICT_KINDS:
60
+ if text is not None:
61
+ st.said = str(text)
62
+ st.spoke = True
63
+ elif e.kind in _THINK_KINDS:
64
+ if text is not None:
65
+ st.thought = str(text)
66
+ elif text is not None:
67
+ # A custom namespaced kind that carries text → treat as an utterance,
68
+ # so a drop-in agent renders on stage with zero presenter edits.
69
+ st.said = str(text)
70
+ st.spoke = True
71
+ # Paired private thought / mood ride as optional payload fields (ADR-0021).
72
+ if e.payload.get("thought"):
73
+ st.thought = str(e.payload["thought"])
74
+ if e.payload.get("mood"):
75
+ st.mood = str(e.payload["mood"])
76
+ st.last_turn = e.turn
77
+ return state
src/ui/fishbowl/view_model.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``view_model_at`` — a JSON-serialisable snapshot of the world at a scrubbed step.
2
+
3
+ This is the single object the Show renders: cast cards, the narrator feed, meters, the
4
+ verdict. It is a pure function of ``events[:k]`` (the same prefix-replay discipline as
5
+ ``rebuild_stage``), so the transport can scrub anywhere and a future ``gr.Server`` can
6
+ serve the very same dict as JSON. Token/round meters read real data from the run rather
7
+ than the prototype's fakes (G9).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Iterable, Sequence
13
+
14
+ from src.core.events import Event
15
+ from src.core.governor import Governor
16
+ from src.core.manifest import AgentManifest
17
+ from src.core.projections import rebuild_stage
18
+ from src.models.provider import estimate_tokens
19
+ from src.ui.fishbowl.adapter import (
20
+ VOICES,
21
+ agent_archetype,
22
+ agent_hue,
23
+ event_to_feed_item,
24
+ model_tier,
25
+ mood_label,
26
+ normalize_mood,
27
+ scenario_voice,
28
+ )
29
+ from src.ui.fishbowl.cast_state import derive_cast_state
30
+
31
+ # Kinds whose actor, when the head event, lights the "speaking" ring on a card.
32
+ _SPEAKING_KINDS = frozenset({"agent.spoke", "agent.thought", "oracle.spoke", "judge.verdict"})
33
+
34
+
35
+ def _estimate_tokens_through(events: Sequence[Event]) -> int:
36
+ """A real-text token estimate for the scrubber meter (grows as you advance)."""
37
+ total = 0
38
+ for e in events:
39
+ text = e.payload.get("text") or e.payload.get("summary") or ""
40
+ total += estimate_tokens(str(text))
41
+ return total
42
+
43
+
44
+ def view_model_at(
45
+ events: Iterable[Event],
46
+ k: int,
47
+ cast: Sequence[AgentManifest],
48
+ *,
49
+ scenario_name: str = "",
50
+ goal: str = "",
51
+ governor: Governor | None = None,
52
+ voice: str | None = None,
53
+ token_ceiling: int | None = None,
54
+ max_rounds: int | None = None,
55
+ ) -> dict:
56
+ """Build the Show's snapshot at step *k* (clamped to ``[0, len(events)]``)."""
57
+ events = tuple(events)
58
+ n = len(events)
59
+ k = max(0, min(int(k), n))
60
+ prefix = events[:k]
61
+
62
+ stage = rebuild_stage(prefix)
63
+ names = [m.name for m in cast]
64
+ states = derive_cast_state(prefix, names)
65
+
66
+ speaking_id: str | None = None
67
+ if k > 0:
68
+ head = events[k - 1]
69
+ if (head.kind in _SPEAKING_KINDS or "text" in head.payload) and head.actor in names:
70
+ speaking_id = head.actor
71
+
72
+ cast_vm = []
73
+ for m in cast:
74
+ st = states[m.name]
75
+ cast_vm.append(
76
+ {
77
+ "id": m.name,
78
+ "name": m.name,
79
+ "archetype": agent_archetype(m),
80
+ "hue": agent_hue(m),
81
+ "role": m.role,
82
+ "model_profile": m.model_profile,
83
+ "tier": model_tier(m.model_profile),
84
+ "said": st.said,
85
+ "thought": st.thought,
86
+ "mood": normalize_mood(st.mood),
87
+ "mood_label": mood_label(st.mood),
88
+ "spoke": st.spoke,
89
+ "speaking": m.name == speaking_id,
90
+ }
91
+ )
92
+
93
+ feed = []
94
+ for e in prefix:
95
+ item = event_to_feed_item(e, names)
96
+ if item is not None:
97
+ item["turn"] = e.turn
98
+ feed.append(item)
99
+
100
+ verdict = None
101
+ for e in prefix:
102
+ if e.kind == "judge.verdict":
103
+ verdict = {
104
+ "text": e.payload.get("text", ""),
105
+ "reveal": e.payload.get("reveal", []),
106
+ "agent": e.actor,
107
+ }
108
+
109
+ rounds = 1 + sum(1 for e in prefix if e.kind == "user.injected")
110
+ chosen_voice = voice or scenario_voice(scenario_name)
111
+ voice_name, voice_desc = VOICES.get(chosen_voice, ("NARRATOR", ""))
112
+
113
+ return {
114
+ "step": k,
115
+ "total": n,
116
+ "scene": stage.current_scene,
117
+ "seed": stage.seed,
118
+ "goal": goal or stage.goal,
119
+ "cast": cast_vm,
120
+ "feed": feed,
121
+ "voice": chosen_voice,
122
+ "voice_meta": {"name": voice_name, "desc": voice_desc},
123
+ "speaking_id": speaking_id,
124
+ "verdict": verdict,
125
+ "rounds": rounds,
126
+ "max_rounds": max_rounds,
127
+ "tokens": _estimate_tokens_through(prefix),
128
+ "tokens_real": dict(governor.stats) if governor is not None else None,
129
+ "token_ceiling": token_ceiling,
130
+ }
tests/test_fishbowl.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fishbowl presenter — cast-state projection, adapter mapping, view-model snapshot.
2
+
3
+ The marquee proof is :class:`TestOfflineEmitsMoodAndThought`: with no API key, a real
4
+ conductor run produces a ledger that carries the say-vs-think ``thought``/``mood`` the
5
+ UI renders — so the mind-reader is genuinely model-driven offline (ADR-0021). Zero
6
+ mocks, per the repo convention.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from src.core.conductor import Conductor
12
+ from src.core.events import Event
13
+ from src.core.ledger_factory import make_ledger
14
+ from src.core.registry import default_registry
15
+ from src.tools.builtins import default_tool_registry
16
+ from src.ui.fishbowl import adapter, derive_cast_state, view_model_at
17
+
18
+
19
+ def _ev(kind: str, actor: str, turn: int = 1, **payload) -> Event:
20
+ return Event(run_id="r", turn=turn, kind=kind, actor=actor, payload=payload)
21
+
22
+
23
+ class TestDeriveCastState:
24
+ def test_spoke_with_thought_and_mood(self):
25
+ events = (_ev("agent.spoke", "pocket-actor", text="I want the moon", thought="secretly scared", mood="panic"),)
26
+ st = derive_cast_state(events, ["pocket-actor"])["pocket-actor"]
27
+ assert st.said == "I want the moon"
28
+ assert st.thought == "secretly scared"
29
+ assert st.mood == "panic"
30
+ assert st.spoke is True
31
+
32
+ def test_thought_only_agent_has_no_said(self):
33
+ events = (_ev("agent.thought", "echo", text="the wood holds its breath", mood="thinking"),)
34
+ st = derive_cast_state(events, ["echo"])["echo"]
35
+ assert st.thought == "the wood holds its breath"
36
+ assert st.said is None
37
+ assert st.mood == "thinking"
38
+
39
+ def test_latest_wins_and_prefix_replay_is_pure(self):
40
+ events = (
41
+ _ev("agent.spoke", "pocket-actor", turn=1, text="first", mood="calm"),
42
+ _ev("agent.spoke", "pocket-actor", turn=2, text="second", mood="smug"),
43
+ )
44
+ assert derive_cast_state(events, ["pocket-actor"])["pocket-actor"].said == "second"
45
+ # scrub back to the prefix — deterministic, no mutation of the log
46
+ assert derive_cast_state(events[:1], ["pocket-actor"])["pocket-actor"].said == "first"
47
+ assert derive_cast_state(events[:1], ["pocket-actor"])["pocket-actor"].said == "first"
48
+
49
+ def test_unknown_actor_is_ignored(self):
50
+ states = derive_cast_state((_ev("agent.spoke", "stranger", text="hi"),), ["pocket-actor"])
51
+ assert states["pocket-actor"].said is None
52
+ assert "stranger" not in states
53
+
54
+ def test_reflection_does_not_touch_said_or_thought(self):
55
+ events = (_ev("agent.reflected", "scene-whisperer", text="I am patient"),)
56
+ st = derive_cast_state(events, ["scene-whisperer"])["scene-whisperer"]
57
+ assert st.said is None and st.thought is None
58
+
59
+
60
+ class TestAdapter:
61
+ def test_hue_prefers_manifest_else_derives_stably(self):
62
+ class WithHue:
63
+ name, hue, archetype, role = "x", 42, None, "worker"
64
+
65
+ class NoHue:
66
+ name, hue, archetype, role = "echo", None, None, "worker"
67
+
68
+ assert adapter.agent_hue(WithHue()) == 42
69
+ h = adapter.agent_hue(NoHue())
70
+ assert 0 <= h < 360 and adapter.agent_hue(NoHue()) == h # stable
71
+
72
+ def test_tier_mapping(self):
73
+ assert adapter.model_tier("tiny") == "fast"
74
+ assert adapter.model_tier("balanced") == "mid"
75
+ assert adapter.model_tier("strong") == "deep"
76
+
77
+ def test_mood_normalization(self):
78
+ assert adapter.normalize_mood("panic") == "panic"
79
+ assert adapter.normalize_mood("curious") == "calm"
80
+ assert adapter.normalize_mood(None) == "calm"
81
+
82
+ def test_feed_vocabulary(self):
83
+ assert adapter.event_to_feed_item(_ev("world.observed", "sw", text="x"))["kind"] == "narrate"
84
+ assert adapter.event_to_feed_item(_ev("user.injected", "visitor", text="x", label="GUST"))["label"] == "GUST"
85
+ assert adapter.event_to_feed_item(_ev("user.injected", "visitor", text="x"))["label"] == "DISTURBANCE"
86
+ assert adapter.event_to_feed_item(_ev("judge.verdict", "j", text="guilty"))["kind"] == "verdict"
87
+ assert adapter.event_to_feed_item(_ev("run.started", "conductor", seed="s")) is None
88
+
89
+
90
+ class TestViewModel:
91
+ def _events(self) -> tuple[Event, ...]:
92
+ return (
93
+ _ev("run.started", "conductor", turn=0, seed="seed", goal="g"),
94
+ _ev("world.observed", "scene-whisperer", turn=1, text="the wood wakes"),
95
+ _ev("agent.spoke", "pocket-actor", turn=2, text="I want the moon", thought="scared", mood="panic"),
96
+ _ev("user.injected", "visitor", turn=3, text="a lantern hums", label="POKE"),
97
+ _ev("judge.verdict", "mischief-critic", turn=4, text="keep it", mood="smug"),
98
+ )
99
+
100
+ def _cast(self):
101
+ scenario = default_registry().build_scenario("thousand-token-wood", tools=default_tool_registry())
102
+ return [a.manifest for a in scenario.agents]
103
+
104
+ def test_snapshot_shape(self):
105
+ events, cast = self._events(), self._cast()
106
+ vm = view_model_at(events, len(events), cast, scenario_name="thousand-token-wood")
107
+ assert vm["step"] == vm["total"] == len(events)
108
+ assert vm["scene"] == "the wood wakes"
109
+ pa = next(c for c in vm["cast"] if c["id"] == "pocket-actor")
110
+ assert pa["said"] == "I want the moon" and pa["thought"] == "scared" and pa["mood"] == "panic"
111
+ kinds = {f["kind"] for f in vm["feed"]}
112
+ assert {"narrate", "say", "poke", "verdict"} <= kinds # run.started omitted
113
+ assert vm["verdict"]["text"] == "keep it"
114
+ assert vm["rounds"] == 2 # one poke
115
+
116
+ def test_prefix_is_clamped_and_tokens_grow(self):
117
+ events, cast = self._events(), self._cast()
118
+ vm0 = view_model_at(events, 0, cast)
119
+ vm_all = view_model_at(events, 999, cast) # clamps to len
120
+ assert vm0["step"] == 0 and vm_all["step"] == len(events)
121
+ assert vm_all["tokens"] >= vm0["tokens"]
122
+
123
+ def test_speaking_id_tracks_the_head(self):
124
+ events, cast = self._events(), self._cast()
125
+ vm = view_model_at(events, 3, cast) # head is pocket-actor's spoke
126
+ assert vm["speaking_id"] == "pocket-actor"
127
+
128
+
129
+ class TestOfflineEmitsMoodAndThought:
130
+ """With no API key the ledger itself carries the say-vs-think data (ADR-0021)."""
131
+
132
+ def _run(self, scenario: str, steps: int = 6) -> Conductor:
133
+ reg = default_registry()
134
+ c = Conductor(
135
+ reg.build_scenario(scenario, tools=default_tool_registry()),
136
+ governor=reg.governor_for(scenario),
137
+ ledger=make_ledger(),
138
+ )
139
+ c.reset(c.scenario.default_seed)
140
+ c.step(n_ticks=steps)
141
+ return c
142
+
143
+ def test_pocket_actor_spoke_carries_thought_and_mood(self):
144
+ c = self._run("thousand-token-wood")
145
+ spoke = [e for e in c.ledger.events if e.kind == "agent.spoke" and e.actor == "pocket-actor"]
146
+ assert spoke, "pocket-actor (tick_every=2) should speak within a few ticks"
147
+ payload = spoke[-1].payload
148
+ assert payload.get("thought"), "the say-vs-think thought must be in the ledger offline"
149
+ assert payload.get("mood"), "the mood must be in the ledger offline"
150
+ assert payload.get("_raw_fallback") is None, "structured output should be clean offline"
151
+
152
+ def test_opt_out_agent_payload_has_no_extra_fields(self):
153
+ c = self._run("thousand-token-wood")
154
+ obs = [e for e in c.ledger.events if e.kind == "world.observed" and e.actor == "scene-whisperer"]
155
+ assert obs
156
+ # scene-whisperer declares no output_extra_fields → no thought/mood leak.
157
+ assert "thought" not in obs[-1].payload and "mood" not in obs[-1].payload
158
+
159
+ def test_view_model_from_a_live_offline_run(self):
160
+ c = self._run("thousand-token-wood")
161
+ cast = [a.manifest for a in c.scenario.agents]
162
+ vm = view_model_at(
163
+ c.ledger.events,
164
+ len(c.ledger.events),
165
+ cast,
166
+ scenario_name="thousand-token-wood",
167
+ governor=c.governor,
168
+ )
169
+ assert vm["cast"] and vm["tokens_real"] is not None
170
+ # the mind-reader has something real to show: a thought and/or a vivid mood.
171
+ assert any(c2["thought"] for c2 in vm["cast"]) or ({c2["mood"] for c2 in vm["cast"]} - {"calm"})