diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 1e27baf3f..4c98b0430 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -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. diff --git a/docs/architecture/11-frozen-contracts-v1.md b/docs/architecture/11-frozen-contracts-v1.md index 51e65a950..f1b4fd640 100644 --- a/docs/architecture/11-frozen-contracts-v1.md +++ b/docs/architecture/11-frozen-contracts-v1.md @@ -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::`, 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::`, 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` | diff --git a/ouroboros/gateway/contracts.py b/ouroboros/gateway/contracts.py index cf83d24d1..d49585aa5 100644 --- a/ouroboros/gateway/contracts.py +++ b/ouroboros/gateway/contracts.py @@ -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] diff --git a/ouroboros/gateway/history.py b/ouroboros/gateway/history.py index e212d91d2..78617c395 100644 --- a/ouroboros/gateway/history.py +++ b/ouroboros/gateway/history.py @@ -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"): diff --git a/ouroboros/gateway/state.py b/ouroboros/gateway/state.py index d3d5d1468..cbf1b0bb9 100644 --- a/ouroboros/gateway/state.py +++ b/ouroboros/gateway/state.py @@ -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() diff --git a/ouroboros/owner_quiz.py b/ouroboros/owner_quiz.py index 8ecb5dfaf..bdf517fb6 100644 --- a/ouroboros/owner_quiz.py +++ b/ouroboros/owner_quiz.py @@ -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] = {} diff --git a/ouroboros/project_dialogue.py b/ouroboros/project_dialogue.py index f703d57cb..3e3218953 100644 --- a/ouroboros/project_dialogue.py +++ b/ouroboros/project_dialogue.py @@ -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 {}), diff --git a/ouroboros/tools/core_artifacts.py b/ouroboros/tools/core_artifacts.py index 713aaf61f..7182d1d08 100644 --- a/ouroboros/tools/core_artifacts.py +++ b/ouroboros/tools/core_artifacts.py @@ -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" diff --git a/supervisor/events_chat_delivery.py b/supervisor/events_chat_delivery.py index dd5ef71c6..a5d967e18 100644 --- a/supervisor/events_chat_delivery.py +++ b/supervisor/events_chat_delivery.py @@ -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: diff --git a/supervisor/message_bus.py b/supervisor/message_bus.py index e61784c43..391f680c7 100644 --- a/supervisor/message_bus.py +++ b/supervisor/message_bus.py @@ -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) diff --git a/tests/test_project_question_pointer.py b/tests/test_project_question_pointer.py index edb3933e4..4903c5da7 100644 --- a/tests/test_project_question_pointer.py +++ b/tests/test_project_question_pointer.py @@ -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") diff --git a/tests/test_quiz_host_facts.py b/tests/test_quiz_host_facts.py new file mode 100644 index 000000000..0e6da7576 --- /dev/null +++ b/tests/test_quiz_host_facts.py @@ -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"] == "" diff --git a/web/modules/api_types.js b/web/modules/api_types.js index 321e6eb55..6e7f3f642 100644 --- a/web/modules/api_types.js +++ b/web/modules/api_types.js @@ -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 diff --git a/web/modules/chat_decision.js b/web/modules/chat_decision.js index ec5aa5638..2402882df 100644 --- a/web/modules/chat_decision.js +++ b/web/modules/chat_decision.js @@ -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'); diff --git a/web/style.css b/web/style.css index 2e699b059..4d416ae75 100644 --- a/web/style.css +++ b/web/style.css @@ -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; diff --git a/web/tests/chat_decision.test.js b/web/tests/chat_decision.test.js index e66cb0c7f..8c40501eb 100644 --- a/web/tests/chat_decision.test.js +++ b/web/tests/chat_decision.test.js @@ -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(); } +});