Show host facts on owner quiz cards and name the Project on chat.quiz

A root's owner card now carries one host-written sentence under the
question: the asking task, how its run started (read from the typed
run_origin provenance of the task record: the owner's message and its
time, a scheduled follow-up of task X, background consciousness, a
promotion, a schedule, or origin unknown with the recorded marker), and
when the owner last wrote in the card's chat, read from the chat log
tail. It is computed from host records only, never from the question
text, and an unrecorded fact is written as unknown.

The sentence is stored in the owner_quiz block (record_asked host_facts),
forwarded by the send_quiz event handler, and carried by the live quiz
frame, the chat.quiz host event, the chat row, history replay (with the
block as the source for rows logged without it), the Main question
pointer, the activity census and the browser mirror. The web card renders
it as a muted plain-text line (.chat-quiz-host-facts), absent when empty.
The chat.quiz event also carries the Project name for a Project card;
the browser wire does not.

QuizOutbound and ChatOutbound gain the optional host_facts field in both
language mirrors; the frozen-contract row and DESIGN.md describe it. No
gateway version change.
This commit is contained in:
Anton 2026-09-25 23:19:30 +03:00
parent 55ad78213a
commit 2f977d78df
16 changed files with 386 additions and 14 deletions

View file

@ -645,7 +645,11 @@ answer keep both forms readable. Anatomy, top to bottom:
contain paragraphs, lists, checklists, tables and code; those blocks keep
the shared rich-content gutter, rhythm and bounded code scrolling. The card
does not infer a title from the first line or rewrite authored Markdown to
make it fit.
make it fit, and the question has no quiz-specific length cap. Directly under
it, a muted plain-text host line (`.chat-quiz-host-facts`, `--type-meta`,
`--text-meta`) states what only the host knows: the asking task, how its run
started and when the owner last wrote in this chat, with unknown facts said
as unknown; the line is absent when the card carries no `host_facts`.
3. **Stake** — optional one-liner (`At stake: …`), `--type-meta`, `--text-meta`.
4. **Options** — real owner actions: buttons with `--text-primary` labels,
legible at rest; an optional per-option detail steps down to meta ink.

View file

@ -28,7 +28,7 @@ This chapter owns the ABI promise: which typed shapes and their parsing, normali
| `ChatOutbound.initiator` — additive origin label of a self-initiated turn (`"consciousness"` on every frame and chat/progress/summary row of a consciousness wake-up and of the roots it starts; absent on an owner's turn); stamped by the turn's own event queue and the agent's frame meta, persisted by `log_chat`/the authored summary row/the task result, replayed by history on each row | `ouroboros/gateway/contracts.py`, `supervisor/log_addressing.py`, `ouroboros/subagent_messages.py`, `supervisor/message_bus.py`, `ouroboros/gateway/history.py`, `web/modules/api_types.js` | `tests/test_consciousness_initiator_label.py`, `tests/test_consciousness_wake_lane.py`, `web/tests/consciousness_label.test.js` |
| `project_thread` stamp on all seven outbound frame types, stamped at the message-bus broadcast choke; a stamped frame is never adopted by Main (`chat_activity.mainThreadAccepts`) | `supervisor/message_bus.py`, `ouroboros/projects_registry.py` | `tests/test_message_bus.py`, `web/tests/chat_thread_routing.test.js` |
| Media/link envelopes — media `task_id`/`size_bytes`/`download_url`; `LinkAction {label,url}` with at most twelve absolute HTTP(S) actions; `links` in `WS_MESSAGE_TYPES`; `chat.links` host topic | `ouroboros/gateway/contracts.py`, `ouroboros/tools/core.py`, `ouroboros/event_bus.py` | `tests/test_contracts.py` |
| Owner quiz ABI — `QuizOption {label, detail?, recommended?}` (the asker marks its recommendation on that option; the web card badges it, Telegram stars its button, the durable block keeps `recommended_index`), `QuizOutbound` (quiz_id, question, options, stake, `assumption` (required for optional clarification), additive `wait_for_answer` for a live pooled or ordinary-conversation root that must wait, lifecycle state open/answered/expired_terminal/superseded), separate `QuizStateOutbound` discriminator, `chat.quiz` host topic; the producer is the one escalation verb `escalate(question, options, stake, assumption, wait_for_answer=False, max_wait_minutes=None)` (the bound applies to a required wait only, never past the task's own deadline: the wait resumes with a system notice and the card stays open; named on an optional question it takes the omitted path and the asker's receipt says so) — a ROOT asks the owner, a SUBAGENT delivers a typed frame to its nearest LIVE ancestor, which answers via `forward_to_worker` or escalates verbatim, so the owner sees only what no ancestor answered; answers arrive through the ONE ingress `POST /api/decisions` (family ids `quiz:{task_id}:{quiz_id}`, `routing:{client_message_id}:{routing_token}`; `interaction:` reserved), request-id idempotent, first answer wins, validated against the STORED options; `option_index` is optional for the quiz family alone — a comment-only answer writes NO `answered_index`, because a stored 0 would replay as "chose the first option"; injected as the typed `KIND_QUIZ_ANSWER` mailbox control and broadcast as `quiz_state` (carrying the recorded `comment` when the owner answered in their own words, so the live card shows `Owner's answer:` exactly as replay does); expiry is structural only (the task-done seam flips open quizzes to `expired_terminal`, and the SAME reconcile closes the paired `owner_wait` so a terminal task never projects `quiz=expired_terminal` beside `owner_wait=waiting`; `owner_wait.set_owner_wait`'s refusal to continue waiting on a terminal result is preserved, not caught); a LATE answer to such an expired card is nevertheless ACCEPTED at the same ingress (the projection records `answered_after_terminal`) and, because no mailbox will ever be drained, is delivered as the owner's OWN message into the card's chat through the named ingress `supervisor.message_bus.accept_local_message`, idempotent on `client_message_id = quiz_late_answer:<task>:<quiz>`, its provenance in the message's own `late_answer` metadata rather than a substituted `client_surface`; the 2xx says `forwarded` so no surface claims a delivery that did not happen, and 409 is left for what is genuinely settled (an already answered card, a non-root addressee). History replay merges the projection state | `ouroboros/gateway/contracts.py`, `ouroboros/gateway/task_decision.py`, `ouroboros/owner_quiz.py`, `ouroboros/tools/core.py` | `tests/test_gateway_parity.py`, `tests/test_quiz_display.py`, `tests/test_quiz_answer.py`, `web/tests/chat_decision.test.js` |
| Owner quiz ABI — `QuizOption {label, detail?, recommended?}` (the asker marks its recommendation on that option; the web card badges it, Telegram stars its button, the durable block keeps `recommended_index`), `QuizOutbound` (quiz_id, question, options, stake, `assumption` (required for optional clarification), additive `wait_for_answer` for a live pooled or ordinary-conversation root that must wait, lifecycle state open/answered/expired_terminal/superseded, optional host-written `host_facts` sentence, also on history rows and Main pointers), separate `QuizStateOutbound` discriminator, `chat.quiz` host topic (+ optional event-only `project_name`); the producer is the one escalation verb `escalate(question, options, stake, assumption, wait_for_answer=False, max_wait_minutes=None)` (the bound applies to a required wait only, never past the task's own deadline: the wait resumes with a system notice and the card stays open; named on an optional question it takes the omitted path and the asker's receipt says so) — a ROOT asks the owner, a SUBAGENT delivers a typed frame to its nearest LIVE ancestor, which answers via `forward_to_worker` or escalates verbatim, so the owner sees only what no ancestor answered; answers arrive through the ONE ingress `POST /api/decisions` (family ids `quiz:{task_id}:{quiz_id}`, `routing:{client_message_id}:{routing_token}`; `interaction:` reserved), request-id idempotent, first answer wins, validated against the STORED options; `option_index` is optional for the quiz family alone — a comment-only answer writes NO `answered_index`, because a stored 0 would replay as "chose the first option"; injected as the typed `KIND_QUIZ_ANSWER` mailbox control and broadcast as `quiz_state` (carrying the recorded `comment` when the owner answered in their own words, so the live card shows `Owner's answer:` exactly as replay does); expiry is structural only (the task-done seam flips open quizzes to `expired_terminal`, and the SAME reconcile closes the paired `owner_wait` so a terminal task never projects `quiz=expired_terminal` beside `owner_wait=waiting`; `owner_wait.set_owner_wait`'s refusal to continue waiting on a terminal result is preserved, not caught); a LATE answer to such an expired card is nevertheless ACCEPTED at the same ingress (the projection records `answered_after_terminal`) and, because no mailbox will ever be drained, is delivered as the owner's OWN message into the card's chat through the named ingress `supervisor.message_bus.accept_local_message`, idempotent on `client_message_id = quiz_late_answer:<task>:<quiz>`, its provenance in the message's own `late_answer` metadata rather than a substituted `client_surface`; the 2xx says `forwarded` so no surface claims a delivery that did not happen, and 409 is left for what is genuinely settled (an already answered card, a non-root addressee). History replay merges the projection state | `ouroboros/gateway/contracts.py`, `ouroboros/gateway/task_decision.py`, `ouroboros/owner_quiz.py`, `ouroboros/tools/core.py` | `tests/test_gateway_parity.py`, `tests/test_quiz_display.py`, `tests/test_quiz_answer.py`, `web/tests/chat_decision.test.js` |
| Managed update ABI — preflight, `UpdateMergePlan`, pinned apply, process-local `update_progress`, `update_progress_changed` invalidation and boot-only `update_status_ready` | `ouroboros/gateway/contracts.py` | `tests/test_update_apply_routing.py` |
| `ChatOutbound.review_projection` — bounded actor findings via `utils.truncate_review_artifact`, at most `MAX_PROJECTED_ACTOR_FINDINGS` rows (`review_execution_projection.py`) | `ouroboros/gateway/contracts.py` | `tests/test_review_substrate_v2.py`, `web/tests/review_truth.test.js` |
| Skill preflight statuses — `preflight_failed` is fresh-only; a stale failure surfaces as `preflight_failed_stale`; absence means the caller could not know | `ouroboros/skill_review_status.py` | `tests/test_skill_preflight_repair.py`, `web/tests/skill_preflight_repair.test.js` |

View file

@ -148,6 +148,7 @@ class ChatOutbound(TypedDict):
recommended_index: NotRequired[int]
answered_index: NotRequired[int]
comment: NotRequired[str]
host_facts: NotRequired[str] # the mirrored card's host sentence (QuizOutbound.host_facts)
task_incident: NotRequired[str]
# A cancellation fault names the PHYSICAL task it could not settle when it differs from the logical task id.
cancel_physical_task_id: NotRequired[str]
@ -387,8 +388,7 @@ class QuizOutbound(TypedDict):
Optional questions continue under ``assumption``; ``wait_for_answer`` marks
required waiting without an implied answer. ``state`` carries lifecycle.
Replay merges the stored ``answered_index`` and verbatim ``comment``; a
comment without an index is the owner's whole answer, not an option choice.
"""
comment without an index is the owner's whole answer, not an option choice."""
type: Literal["quiz"]
role: Literal["assistant"]
@ -402,6 +402,7 @@ class QuizOutbound(TypedDict):
ts: str
answered_index: NotRequired[int]
comment: NotRequired[str]
host_facts: NotRequired[str] # host-written, never the model's: asking task, run start, owner's last message
chat_id: NotRequired[int]
task_id: NotRequired[str]
project_thread: NotRequired[bool]

View file

@ -881,6 +881,8 @@ def _collect_chat_rows(
for key in ("answered_index", "comment", "wait_ended_at"): # the answer, the closed bound
if key in _live:
quiz[key] = _live[key]
if _live.get("host_facts") and not quiz.get("host_facts"):
quiz["host_facts"] = str(_live["host_facts"]) # the ask-time sentence the block holds
if "wait_for_answer" not in _live:
quiz.pop("wait_for_answer", None) # the bound closed: the card no longer waits
if quiz.get("wait_for_answer") or quiz.get("wait_ended_at"):

View file

@ -322,7 +322,8 @@ def _task_activity_facts(drive_root: Any, task_id: str) -> dict:
# (project_dialogue.project_question_pointer): display fields ride along.
"quiz": {key: quiz[key] for key in ("quiz_id", "state", "asked_at", "wait_for_answer", "question",
"options", "option_details", "stake", "assumption",
"recommended_index", "answered_index", "comment", "wait_ended_at")
"recommended_index", "answered_index", "comment", "wait_ended_at",
"host_facts")
if isinstance(quiz, dict) and key in quiz}}
if len(_FINALIZING_MEMO) >= _FINALIZING_MEMO_MAX:
_FINALIZING_MEMO.clear()

View file

@ -17,7 +17,7 @@ like the hurry projection):
"assumption", "state": open|answered|expired_terminal,
"asked_at", "answered_at"?, "answered_index"?, "request_id"?,
"comment"?, "reconciled_at"?, "chat_id"?, "max_wait_minutes"?,
"answered_after_terminal"?,
"answered_after_terminal"?, "host_facts"?,
}, ...
}
@ -121,6 +121,7 @@ def record_asked(
recommended_index: Optional[int] = None,
chat_id: Optional[int] = None,
max_wait_minutes: Optional[int] = None,
host_facts: str = "",
) -> Dict[str, Any]:
"""Worker-side projection write at ask time.
@ -132,7 +133,10 @@ def record_asked(
real hidden partition, never "no chat"): a late answer arriving after the
task is gone is delivered there as an ordinary owner message instead of
into a mailbox nobody drains. ``max_wait_minutes`` records the bound a
waiting asker chose, so replay can say what the task waited for."""
waiting asker chose, so replay can say what the task waited for.
``host_facts`` is the host-written sentence the card shows under the
question (asking task, how its run started, the owner's last message in
the chat); stored only when non-empty."""
if option_details is not None and (
not isinstance(option_details, list) or len(option_details) != len(options)
or not all(isinstance(value, str) for value in option_details)
@ -150,6 +154,7 @@ def record_asked(
**({"chat_id": int(chat_id)} if isinstance(chat_id, int) and not isinstance(chat_id, bool) else {}),
**({"max_wait_minutes": int(max_wait_minutes)}
if isinstance(max_wait_minutes, int) and not isinstance(max_wait_minutes, bool) else {}),
**({"host_facts": str(host_facts)} if str(host_facts or "") else {}),
}
refused: Dict[str, str] = {}

View file

@ -134,6 +134,7 @@ def project_question_pointer(row: Dict[str, Any], block: Any, project: Any,
question = str(quiz.get("question") or row.get("text") or block.get("question") or "")
assumption = str(quiz.get("assumption") or block.get("assumption") or "")
stake = str(quiz.get("stake") or block.get("stake") or "")
host_facts = str(quiz.get("host_facts") or block.get("host_facts") or "")
recommended = block.get("recommended_index")
if not isinstance(recommended, int) or isinstance(recommended, bool):
recommended = next((i for i, option in enumerate(options)
@ -151,6 +152,7 @@ def project_question_pointer(row: Dict[str, Any], block: Any, project: Any,
**({"option_details": details} if details else {}),
**({"stake": stake} if stake else {}),
**({"assumption": assumption} if assumption else {}),
**({"host_facts": host_facts} if host_facts else {}),
**({"recommended_index": recommended} if recommended is not None else {}),
**facts,
**({"source_status": "unavailable"} if not known else {}),

View file

@ -436,6 +436,71 @@ def _send_links(
return "OK: link buttons queued for delivery to owner."
def _quiz_host_facts(ctx: ToolContext, canonical_root: pathlib.Path, task_id: str, chat_id: int) -> str:
"""The host's one-sentence account under a root's owner card: which task asks,
how its run started, and when the owner last wrote in this chat. Read from
typed records only (the task record's ``run_origin`` provenance and the chat
log tail), never from the question text; an unrecorded fact says unknown."""
import datetime
from ouroboros.consciousness_authority import CONSCIOUSNESS_INITIATOR
from ouroboros.dialogue_provenance import run_origin
from ouroboros.task_results import load_task_result
from ouroboros.tools.followup import FOLLOWUP_SOURCE
from ouroboros.utils import iter_jsonl_objects
def moment(value: Any) -> Optional[datetime.datetime]:
try:
parsed = datetime.datetime.fromisoformat(str(value or ""))
except ValueError:
return None
return (parsed if parsed.tzinfo else parsed.replace(tzinfo=datetime.timezone.utc)).astimezone(
datetime.timezone.utc)
def shown(when: datetime.datetime) -> str:
return when.strftime("%Y-%m-%d %H:%M UTC")
meta = getattr(ctx, "task_metadata", {}) if isinstance(getattr(ctx, "task_metadata", {}), dict) else {}
try:
record = load_task_result(canonical_root, task_id) or {}
except Exception:
record = {}
# The live metadata (which carries the owner door's stamp) laid over the
# persisted record's own, as the post-task synthesis reads the same origin.
metadata = {**(record.get("metadata") if isinstance(record.get("metadata"), dict) else {}), **meta}
origin = run_origin({**record, "metadata": metadata})
ref = metadata.get("origin_message_ref") or record.get("origin_message_ref")
origin_task = str(origin.get("origin_task_id") or "")
if origin.get("owner_ingress"):
sent = moment(ref.get("ts")) if isinstance(ref, dict) else None
started = "started by your message" + (f" of {shown(sent)}" if sent else "")
elif origin.get("source") == FOLLOWUP_SOURCE and origin_task:
started = f"started as a scheduled follow-up of task {origin_task}"
elif origin.get("initiator") == CONSCIOUSNESS_INITIATOR:
started = "started by background consciousness"
elif origin.get("source") == "promote_chat_to_task":
started = "started by promotion" + (f" from task {origin_task}" if origin_task else "")
elif origin.get("schedule_id"):
started = f"started by schedule {origin['schedule_id']}"
elif origin_task:
started = f"started from task {origin_task}"
else:
started = "origin unknown" + (f" (recorded source: {origin['source']})" if origin.get("source") else "")
last = None
try:
for entry in iter_jsonl_objects(canonical_root / "logs" / "chat.jsonl", tail_bytes=512_000):
if entry.get("direction") == "in" and str(entry.get("chat_id")) == str(chat_id):
last = moment(entry.get("ts")) or last
except Exception:
last = None
if last is None:
seen = "your last message in this chat: unknown"
else:
minutes = max(0, int((datetime.datetime.now(datetime.timezone.utc) - last).total_seconds() // 60))
seen = f"your last message in this chat: {shown(last)} ({minutes} minutes before this question)"
return f"Asked by task {task_id}, {started}; {seen}."
def _escalate(
ctx: ToolContext,
question: str,
@ -581,6 +646,7 @@ def _escalate(
card_chat_id = int(getattr(ctx, "current_chat_id", None) or 0)
except (TypeError, ValueError):
card_chat_id = 0
host_facts = _quiz_host_facts(ctx, canonical_root, task_id, card_chat_id)
asked = record_asked(
canonical_root, task_id,
quiz_id=quiz_id, question=payload["question"],
@ -590,6 +656,7 @@ def _escalate(
stake=payload["stake"], assumption=payload["assumption"],
wait_for_answer=wait_for_answer, chat_id=card_chat_id,
max_wait_minutes=payload.get("max_wait_minutes"),
host_facts=host_facts,
)
if asked.get("refused"):
if wait_for_answer:
@ -610,6 +677,7 @@ def _escalate(
"assumption": payload["assumption"],
"state": "open",
"task_id": task_id,
"host_facts": host_facts,
**({"wait_for_answer": True} if wait_for_answer else {}),
})
delivered = "delivered to the owner" if mode == "live" else "queued for the owner"

View file

@ -457,6 +457,7 @@ def _handle_send_quiz(evt: Dict[str, Any], ctx: Any) -> None:
assumption=str(evt.get("assumption") or ""),
state=str(evt.get("state") or "open"),
task_id=str(evt.get("task_id") or ""),
host_facts=str(evt.get("host_facts") or ""),
**({"wait_for_answer": True} if evt.get("wait_for_answer") is True else {}),
)
if not ok:

View file

@ -957,8 +957,12 @@ class LocalChatBridge:
state: str = "open",
task_id: str = "",
wait_for_answer: bool = False,
host_facts: str = "",
) -> Tuple[bool, str]:
"""Send an owner quiz card to the UI and host event subscribers."""
"""Send an owner quiz card to the UI and host event subscribers.
``host_facts`` (the host's sentence under the question) rides the frame, the
event and the chat row when non-empty; ``project_name`` rides the EVENT only."""
if is_a2a_chat_id(chat_id):
return True, "ok"
qid = str(quiz_id or "").strip()
@ -988,7 +992,18 @@ class LocalChatBridge:
"chat_id": int(chat_id or 0),
"task_id": str(task_id or ""),
}
if host_facts:
msg["host_facts"] = str(host_facts) # the envelope literal keeps constant keys (contract scan)
stamp_project_thread(DATA_DIR, msg)
project = None
if msg.get("project_thread"):
try:
from ouroboros.projects_registry import list_reserved_projects
project = next((row for row in list_reserved_projects(DATA_DIR)
if row.get("chat_id") == int(chat_id)), None)
except Exception:
log.debug("Quiz project lookup failed", exc_info=True)
if self._broadcast_fn:
self._broadcast_fn(msg)
quiz_transport = dict(self._chat_transports.get(int(chat_id or 0), {}) or {})
@ -1004,6 +1019,8 @@ class LocalChatBridge:
"assumption": payload["assumption"],
"state": str(state or "open"),
"ts": ts,
**({"host_facts": str(host_facts)} if host_facts else {}),
**({"project_name": str(project["name"])} if project and project.get("name") else {}),
})
try:
owner_id = int(load_state().get("owner_id") or 0)
@ -1019,6 +1036,7 @@ class LocalChatBridge:
"stake": payload["stake"],
"assumption": payload["assumption"],
"state": str(state or "open"),
**({"host_facts": str(host_facts)} if host_facts else {}),
},
)
_advance_project_visible_revision(chat_id)
@ -1026,10 +1044,7 @@ class LocalChatBridge:
try:
from ouroboros.owner_quiz import quiz_states
from ouroboros.project_dialogue import project_question_pointer
from ouroboros.projects_registry import list_reserved_projects
project = next((row for row in list_reserved_projects(DATA_DIR)
if row.get("chat_id") == int(chat_id)), None)
pointer = project_question_pointer(msg, quiz_states(DATA_DIR, task_id).get(qid), project)
if pointer:
frame = {
@ -1045,7 +1060,7 @@ class LocalChatBridge:
# The complete pointer row (ChatOutbound mirrors): present only when known.
for key in ("question", "options", "option_details", "stake", "assumption", "recommended_index",
"answered_index", "comment", "wait_for_answer", "wait_ended_at",
"owner_wait_resume_reason"):
"owner_wait_resume_reason", "host_facts"):
if key in pointer:
frame[key] = pointer[key]
self._broadcast_fn(frame)

View file

@ -328,11 +328,16 @@ def test_project_question_pointer_display_fields_share_one_contract():
# History: the producer itself emits every field the browser merges.
block = {"quiz_id": "q", "state": "open", "question": "Which?", "options": ["a", "b"],
"option_details": ["A detail", ""], "stake": "What rides on it", "assumption": "a",
"recommended_index": 0}
"recommended_index": 0, "host_facts": "Asked by task t, origin unknown."}
pointer = project_question_pointer({"task_id": "t", "quiz_id": "q"}, block,
{"id": "p", "chat_id": 12, "name": "Project"}, None)
assert browser_fields <= set(pointer), sorted(browser_fields - set(pointer))
assert pointer["option_details"] == ["A detail", ""] and pointer["stake"] == "What rides on it"
assert pointer["host_facts"] == "Asked by task t, origin unknown."
# A block that recorded no host sentence yields a pointer without the key, never an empty one.
bare = project_question_pointer({"task_id": "t", "quiz_id": "q"}, {**block, "host_facts": ""},
{"id": "p", "chat_id": 12, "name": "Project"}, None)
assert "host_facts" not in bare
# Live delivery: the frame literal plus its copied key loop, all declared in ChatOutbound.
send_quiz = _function_node(REPO_ROOT / "supervisor" / "message_bus.py", "send_quiz")

View file

@ -0,0 +1,198 @@
"""Host facts on the owner quiz card (4A) and the Project name on the ``chat.quiz`` event.
The host writes one sentence under the question from facts only it knows — the asking
task, how that task's run started (``dialogue_provenance.run_origin``) and when the
owner last wrote in the card's chat — never from the question text; an unrecorded fact
says unknown. The sentence rides the durable block, the live frame, the host event, the
chat row, history replay and the Main pointer; the Project name rides the event only.
"""
from __future__ import annotations
import datetime
import json
from ouroboros.owner_quiz import quiz_states, record_asked
from ouroboros.task_results import STATUS_RUNNING, write_task_result
from tests.test_quiz_answer import _escalate, _tool_ctx
_NOW = datetime.datetime.now(datetime.timezone.utc)
def _stamp(minutes_ago: float) -> str:
return (_NOW - datetime.timedelta(minutes=minutes_ago)).isoformat()
def _shown(iso: str) -> str:
return datetime.datetime.fromisoformat(iso).astimezone(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
def _chat_rows(tmp_path, *rows):
path = tmp_path / "logs" / "chat.jsonl"
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as handle:
for row in rows:
handle.write(json.dumps(row) + "\n")
def _ask(ctx):
out = _escalate(ctx, question="Started by your message? Ship it?", options=["Ship", "Hold"],
assumption="Hold meanwhile")
assert out.startswith("OK: quiz "), out
event = next(evt for evt in ctx.pending_events if evt.get("type") == "send_quiz")
return event, quiz_states(ctx.drive_root, ctx.task_id)[event["quiz_id"]]
def test_record_asked_stores_host_facts_only_when_present(tmp_path):
with_facts = record_asked(tmp_path, "t-1", quiz_id="q-1", question="Q?", options=["a", "b"],
assumption="a", host_facts="Asked by task t-1, origin unknown.")
assert with_facts["host_facts"] == "Asked by task t-1, origin unknown."
without = record_asked(tmp_path, "t-1", quiz_id="q-2", question="Q?", options=["a", "b"], assumption="a")
assert "host_facts" not in without
stored = quiz_states(tmp_path, "t-1")
assert stored["q-1"]["host_facts"] == "Asked by task t-1, origin unknown."
assert "host_facts" not in stored["q-2"]
def test_owner_started_root_names_its_message_and_the_last_owner_message_in_this_chat(tmp_path):
started, last = _stamp(90.3), _stamp(47.3)
ctx = _tool_ctx(tmp_path, chat_id=1)
ctx.task_metadata["origin_message_ref"] = {
"chat_id": 1, "client_message_id": "m-1", "ts": started, "text_sha256": "0" * 64}
_chat_rows(
tmp_path,
{"ts": started, "direction": "in", "chat_id": 1, "text": "please ship"},
{"ts": last, "direction": "in", "chat_id": 1, "text": "and quickly"},
# Neither the assistant's own row nor another chat's owner message is "your last message here".
{"ts": _stamp(10), "direction": "out", "chat_id": 1, "text": "working"},
{"ts": _stamp(5), "direction": "in", "chat_id": 7, "text": "other room"},
)
event, block = _ask(ctx)
expected = (f"Asked by task root-1, started by your message of {_shown(started)}; "
f"your last message in this chat: {_shown(last)} (47 minutes before this question).")
assert block["host_facts"] == expected
assert event["host_facts"] == expected
def test_scheduled_follow_up_names_the_task_it_follows(tmp_path):
write_task_result(tmp_path, "root-1", STATUS_RUNNING,
metadata={"source": "task_followup", "origin_task_id": "prev-7", "schedule_id": "followup-prev-7"})
event, block = _ask(_tool_ctx(tmp_path, chat_id=1))
assert block["host_facts"] == ("Asked by task root-1, started as a scheduled follow-up of task prev-7; "
"your last message in this chat: unknown.")
assert event["host_facts"] == block["host_facts"]
def test_consciousness_origin_is_named(tmp_path):
ctx = _tool_ctx(tmp_path, chat_id=1)
ctx.task_metadata["initiator"] = "consciousness"
_chat_rows(tmp_path, {"ts": _stamp(3.2), "direction": "in", "chat_id": 1, "text": "hi"})
_event, block = _ask(ctx)
assert block["host_facts"].startswith("Asked by task root-1, started by background consciousness; ")
assert block["host_facts"].endswith("(3 minutes before this question).")
def test_unknown_origin_and_no_owner_message_are_said_as_unknown(tmp_path):
# An unrelated source marker is shown as recorded, never mapped to a guess.
_event, block = _ask(_tool_ctx(tmp_path, chat_id=1))
assert block["host_facts"] == "Asked by task root-1, origin unknown; your last message in this chat: unknown."
write_task_result(tmp_path, "root-2", STATUS_RUNNING, metadata={"source": "mystery_lane"})
_event, block = _ask(_tool_ctx(tmp_path, task_id="root-2", chat_id=1))
assert block["host_facts"] == ("Asked by task root-2, origin unknown (recorded source: mystery_lane); "
"your last message in this chat: unknown.")
def _bridge(tmp_path, monkeypatch):
from supervisor import message_bus, state
monkeypatch.setattr(message_bus, "DATA_DIR", tmp_path)
state.init(tmp_path, 100)
frames, events = [], []
bridge = message_bus.LocalChatBridge({})
bridge._broadcast_fn = frames.append
monkeypatch.setattr(message_bus, "publish_event", lambda topic, data: events.append((topic, data)))
monkeypatch.setattr(message_bus, "get_bridge", lambda: bridge)
return bridge, frames, events
_OPTIONS = [{"label": "Local"}, {"label": "Shared"}]
def test_send_quiz_carries_host_facts_and_names_the_project_on_the_event_only(tmp_path, monkeypatch):
from ouroboros.event_bus import CHAT_QUIZ
from ouroboros.gateway.history import _assemble_history_response
from ouroboros.projects_registry import create_project
project = create_project(tmp_path, "facts-project", name="Facts Project")
record_asked(tmp_path, "task-1", quiz_id="q-1", question="Which storage?", options=["Local", "Shared"],
assumption="Local", chat_id=project["chat_id"], host_facts="Asked by task task-1, origin unknown.")
bridge, frames, events = _bridge(tmp_path, monkeypatch)
ok, _ = bridge.send_quiz(project["chat_id"], quiz_id="q-1", question="Which storage?", options=_OPTIONS,
assumption="Local", task_id="task-1", host_facts="Asked by task task-1, origin unknown.")
assert ok
quiz_frame, pointer = frames
assert quiz_frame["type"] == "quiz" and quiz_frame["host_facts"] == "Asked by task task-1, origin unknown."
assert "project_name" not in quiz_frame # the browser wire names the Project through its own pointer
assert pointer["system_type"] == "project_question_pointer"
assert pointer["host_facts"] == "Asked by task task-1, origin unknown."
[(topic, event)] = events
assert topic == CHAT_QUIZ
assert event["host_facts"] == "Asked by task task-1, origin unknown."
assert event["project_name"] == "Facts Project"
stored = [json.loads(line) for line in (tmp_path / "logs/chat.jsonl").read_text(encoding="utf-8").splitlines()]
assert stored[-1]["quiz"]["host_facts"] == "Asked by task task-1, origin unknown."
room = json.loads(_assemble_history_response(tmp_path, project["chat_id"], 10, 0))["messages"]
assert room[0]["msg_type"] == "quiz" and room[0]["quiz"]["host_facts"] == "Asked by task task-1, origin unknown."
main = json.loads(_assemble_history_response(tmp_path, 1, 10, 0))["messages"]
assert main[0]["system_type"] == "project_question_pointer"
assert main[0]["host_facts"] == "Asked by task task-1, origin unknown."
def test_send_quiz_without_host_facts_or_project_adds_neither(tmp_path, monkeypatch):
bridge, frames, events = _bridge(tmp_path, monkeypatch)
assert bridge.send_quiz(1, quiz_id="q-2", question="Which storage?", options=_OPTIONS,
assumption="Local", task_id="task-2")[0]
[quiz_frame] = frames
[(_topic, event)] = events
assert "host_facts" not in quiz_frame and "host_facts" not in event and "project_name" not in event
stored = json.loads((tmp_path / "logs/chat.jsonl").read_text(encoding="utf-8").splitlines()[-1])
assert "host_facts" not in stored["quiz"]
def test_history_replays_the_block_sentence_for_a_row_logged_without_it(tmp_path, monkeypatch):
from ouroboros.gateway.history import _assemble_history_response
record_asked(tmp_path, "task-3", quiz_id="q-3", question="Which storage?", options=["Local", "Shared"],
assumption="Local", chat_id=1, host_facts="Asked by task task-3, origin unknown.")
record_asked(tmp_path, "task-4", quiz_id="q-4", question="Which storage?", options=["Local", "Shared"],
assumption="Local", chat_id=1)
bridge, _frames, _events = _bridge(tmp_path, monkeypatch)
for task_id, quiz_id in (("task-3", "q-3"), ("task-4", "q-4")):
assert bridge.send_quiz(1, quiz_id=quiz_id, question="Which storage?", options=_OPTIONS,
assumption="Local", task_id=task_id)[0]
rows = {row["quiz"]["quiz_id"]: row["quiz"]
for row in json.loads(_assemble_history_response(tmp_path, 1, 10, 0))["messages"]
if row.get("msg_type") == "quiz"}
assert rows["q-3"]["host_facts"] == "Asked by task task-3, origin unknown."
assert "host_facts" not in rows["q-4"]
def test_the_supervisor_event_handler_forwards_host_facts_to_send_quiz():
from types import SimpleNamespace
from supervisor.events_chat_delivery import _handle_send_quiz
sent = []
class Bridge:
def send_quiz(self, chat_id, **kwargs):
sent.append((chat_id, kwargs))
return True, "ok"
ctx = SimpleNamespace(bridge=Bridge())
evt = {"type": "send_quiz", "chat_id": 1, "quiz_id": "q-5", "question": "Q?", "options": _OPTIONS,
"assumption": "a", "task_id": "t-5", "host_facts": "Asked by task t-5, origin unknown."}
_handle_send_quiz(evt, ctx)
_handle_send_quiz({**evt, "host_facts": ""}, ctx)
assert sent[0][1]["host_facts"] == "Asked by task t-5, origin unknown."
assert sent[1][1]["host_facts"] == ""

View file

@ -297,6 +297,7 @@
* @property {number=} recommended_index
* @property {number=} answered_index
* @property {string=} comment
* @property {string=} host_facts
* @property {"chat"} type
* @property {"user"|"assistant"|"system"} role
* @property {string} content
@ -569,6 +570,7 @@
* @property {string} ts
* @property {number=} answered_index
* @property {string=} comment
* @property {string=} host_facts
* @property {number=} chat_id
* @property {string=} task_id
* @property {boolean=} project_thread

View file

@ -156,7 +156,7 @@ export function createChatDecision({
const MIRROR_SETTLE_MS = 5000;
// Display fields a narrower delivery (the census, a lifecycle frame) may lack: an empty value
// there never blanks what a complete row already carried.
const MIRROR_FIELDS = ['question', 'options', 'option_details', 'stake', 'project_name', 'assumption', 'recommended_index'];
const MIRROR_FIELDS = ['question', 'options', 'option_details', 'stake', 'project_name', 'assumption', 'recommended_index', 'host_facts'];
const MIRROR_SIGNATURE = ['quiz_state', ...MIRROR_FIELDS, 'answered_index', 'comment', ...WAIT_FIELDS];
// The pointer row in the shape of the Project's quiz row, so one normalizer reads both.
const mirrorQuiz = (row) => ({ ...row, type: 'quiz', role: 'assistant', state: row.quiz_state });
@ -373,6 +373,8 @@ export function createChatDecision({
options,
stake: String(src.stake || ''),
assumption: String(src.assumption || ''),
// The host's sentence (asking task, run start, the owner's last message): plain text.
hostFacts: String(src.host_facts || ''),
// The wait facts the header and the signature line read (waitFacts): the
// original required flag, the closed bound, and the task's wait record when
// history or a detail read attached it.
@ -392,6 +394,13 @@ export function createChatDecision({
};
}
function hostFactsLine(text) {
const line = document.createElement('div');
line.className = 'chat-quiz-host-facts';
line.textContent = text;
return line;
}
function appendRecommendedBadge(button) {
// The asker's recommendation (the "A" option) is a badge on that option, every surface alike.
if (button.querySelector('.chat-quiz-option-recommended')) return;
@ -588,6 +597,10 @@ export function createChatDecision({
const wait = waitFacts(current);
const existing = quizViews.get(key);
if (existing) {
// A narrower first delivery may have lacked the host's sentence; a later one adds it.
if (quiz.hostFacts && !existing.querySelector('.chat-quiz-host-facts')) {
existing.querySelector('.chat-quiz-question')?.nextElementSibling?.before(hostFactsLine(quiz.hostFacts));
}
if (quiz.comment) existing.dataset.ownerComment = quiz.comment;
else if (Object.hasOwn(current, 'comment')) delete existing.dataset.ownerComment;
if (!quiz.detailsUnavailable) {
@ -648,6 +661,7 @@ export function createChatDecision({
if (mountMarkdown) mountMarkdown(question, questionText);
else question.textContent = questionText;
card.append(question);
if (quiz.hostFacts) card.append(hostFactsLine(quiz.hostFacts));
if (quiz.stake) {
const stake = document.createElement('div');

View file

@ -7219,6 +7219,14 @@ textarea.chat-input {
min-inline-size: 0;
}
.chat-quiz-stake .inline-code { font-size: inherit; }
/* The host's own sentence under the question (who asks, how the run started, the owner's last message). */
.chat-quiz-host-facts {
font-size: var(--type-meta);
line-height: var(--line-meta, 1.35);
color: var(--text-meta);
min-inline-size: 0;
overflow-wrap: anywhere;
}
.chat-quiz-options {
display: flex;
flex-direction: column;

View file

@ -805,3 +805,49 @@ test('a late answer says where it went', async () => {
} finally { fx.restore(); }
}
});
test('the host facts line sits under the question when the card carries it and is absent otherwise', () => {
const fx = fixture();
const facts = 'Asked by task t-1, started by your message of 2026-09-25 00:21 UTC; '
+ 'your last message in this chat: 2026-09-25 00:21 UTC (47 minutes before this question).';
try {
const card = fx.decision.buildQuizCard({ ...WS_MSG, quiz_id: 'qz-facts', host_facts: facts });
const line = card.querySelector('.chat-quiz-host-facts');
assert.equal(line.textContent, facts);
// Plain text directly under the question, never through the markdown pipeline.
assert.equal(line.innerHTML, undefined);
const question = card.querySelector('.chat-quiz-question');
assert.equal(question.nextElementSibling, line);
// A stored history row nests the quiz; the sentence rides the nested block.
const replay = fx.decision.buildQuizCard({ task_id: 't-1', text: WS_MSG.question,
quiz: { ...WS_MSG, quiz_id: 'qz-facts-replay', host_facts: facts } });
assert.equal(replay.querySelector('.chat-quiz-host-facts').textContent, facts);
const bare = fx.decision.buildQuizCard({ ...WS_MSG, quiz_id: 'qz-bare' });
assert.equal(bare.querySelector('.chat-quiz-host-facts'), null);
const empty = fx.decision.buildQuizCard({ ...WS_MSG, quiz_id: 'qz-empty', host_facts: '' });
assert.equal(empty.querySelector('.chat-quiz-host-facts'), null);
// A later, richer delivery of an already rendered card adds the line once.
assert.equal(fx.decision.buildQuizCard({ ...WS_MSG, quiz_id: 'qz-bare', host_facts: facts }), null);
assert.equal(bare.querySelectorAll('.chat-quiz-host-facts').length, 1);
assert.equal(bare.querySelector('.chat-quiz-question').nextElementSibling.textContent, facts);
fx.decision.buildQuizCard({ ...WS_MSG, quiz_id: 'qz-bare', host_facts: facts });
assert.equal(bare.querySelectorAll('.chat-quiz-host-facts').length, 1);
} finally { fx.restore(); }
});
test('a Main mirror of a Project question carries the host facts line from its pointer row', () => {
const column = new NodeStub();
const fx = fixture({ isMain: true,
frameNode: (_msg, card) => { const bubble = new NodeStub(); bubble.append(card); return bubble; },
insertMessageNode: (node) => { column.append(node); return true; } });
const row = { role: 'system', system_type: 'project_question_pointer', task_id: 't-9', quiz_id: 'qz-9',
project_id: 'p1', project_chat_id: 23, project_name: 'Storage', ts: '2026-09-25T00:00:00+00:00',
quiz_state: 'open', question: 'Merge now?', options: ['Yes', 'No'] };
try {
assert.ok(fx.decision.appendQuestionPointer({ ...row, host_facts: 'Asked by task t-9, origin unknown.' }));
const card = column.children[0].children[0];
assert.equal(card.querySelector('.chat-quiz-host-facts').textContent, 'Asked by task t-9, origin unknown.');
assert.ok(fx.decision.appendQuestionPointer({ ...row, task_id: 't-10' }));
assert.equal(column.children[1].children[0].querySelector('.chat-quiz-host-facts'), null);
} finally { fx.restore(); }
});