fix(chat): mirror complete Project questions in Main and retire answered copies

This commit is contained in:
Ouroboros 2026-09-19 17:49:42 +03:00
parent 86806ee123
commit 885f46beb6
23 changed files with 1396 additions and 754 deletions

View file

@ -1,4 +1,7 @@
"""Project questions share their durable ask with every display lens."""
import ast
import pathlib
import re
import json
import os
@ -23,7 +26,7 @@ def test_real_escalation_bridge_history_detail_and_answer(tmp_path, monkeypatch)
write_task_result(tmp_path, "task-1", STATUS_RUNNING, project_id=project["id"], chat_id=project["chat_id"])
ctx = _tool_ctx(tmp_path, task_id="task-1", chat_id=project["chat_id"])
ctx.owner_wait_callback = lambda *_: None
result = _escalate(ctx, question="Which storage?", wait_for_answer=True,
result = _escalate(ctx, question="Which storage?", wait_for_answer=True, stake="Where every later write lands.",
options=[{"label": "Local", "detail": "Single-machine files"},
{"label": "Shared", "detail": "Multiple writers over network"}])
assert result.startswith("OK:")
@ -45,11 +48,15 @@ def test_real_escalation_bridge_history_detail_and_answer(tmp_path, monkeypatch)
# The live pointer frame is complete for display: the browser paints it without a detail read.
assert pointer["question"] == "Which storage?" and pointer["options"] == ["Local", "Shared"]
assert pointer["wait_for_answer"] is True and pointer["content"] == "Waiting for your answer in Question Project"
# Main mirrors the Project's own form: the option details and the stake ride along too.
details = ["Single-machine files", "Multiple writers over network"]
assert pointer["option_details"] == details and pointer["stake"] == "Where every later write lands."
assert not {"comment", "answered_index"} & pointer.keys()
stored = (tmp_path / "logs/chat.jsonl").read_text().splitlines()
assert len(stored) == 1 and json.loads(stored[0])["chat_id"] == project["chat_id"]
main = json.loads(_assemble_history_response(tmp_path, 1, 10, 0))["messages"]
assert len(main) == 1 and main[0]["quiz_id"] == qid
assert main[0]["option_details"] == details and main[0]["stake"] == "Where every later write lands."
assert not {"task_phase", "outcome_axes", "cost_final", "cancelable"} & main[0].keys()
room = json.loads(_assemble_history_response(tmp_path, project["chat_id"], 10, 0))["messages"]
assert room[0]["msg_type"] == "quiz" and room[0]["quiz"]["options"][1]["detail"] == "Multiple writers over network"
@ -148,7 +155,7 @@ def test_activity_question_uses_same_memo_and_preserves_wait_semantics(tmp_path,
write_task_result(tmp_path, "t1", STATUS_RUNNING, project_id=project["id"],
root_phase_checkpoint={"post_task_synthesis": "running"})
record_asked(tmp_path, "t1", quiz_id="q1", question="?", options=["a", "b"], wait_for_answer=True,
recommended_index=0)
recommended_index=0, option_details=["Alpha", ""], stake="The rest of the run")
set_owner_wait(tmp_path, "t1", {"quiz_id": "q1", "wait_id": "w1", "state": "waiting"})
monkeypatch.setattr(queue, "PENDING", [])
monkeypatch.setattr(queue, "RUNNING", {"t1": {"task": {"id": "t1", "project_id": project["id"], "chat_id": project["chat_id"]}}})
@ -161,6 +168,8 @@ def test_activity_question_uses_same_memo_and_preserves_wait_semantics(tmp_path,
assert rows[0]["required_question"]["text"] == "Waiting for your answer in Waiting Project"
# The census pointer is as complete as the history row: the browser never paints a blank over it.
assert rows[0]["required_question"]["question"] == "?" and rows[0]["required_question"]["options"] == ["a", "b"]
assert rows[0]["required_question"]["option_details"] == ["Alpha", ""]
assert rows[0]["required_question"]["stake"] == "The rest of the run"
# A card painted from the census alone still badges the recommended option (index zero included).
assert rows[0]["required_question"]["recommended_index"] == 0
# Folding an older Main card needs the named question's OWN asked_at, so the census
@ -231,9 +240,12 @@ def test_question_presentation_shared_fixture():
assert pointer["text"] == case["status"] + " in Project", case["case"]
assert {key: pointer[key] for key in keys if key in pointer} == case["row"], case["case"]
assert pointer["question"] == "Which?" and pointer["options"] == ["a", "b"]
# A legacy label-only ask retained no details; Main says so exactly as the Project card does.
assert "option_details" not in pointer and "stake" not in pointer
narrow = project_question_pointer({"task_id": "task", "quiz_id": "q", "wait_for_answer": True},
{"quiz_id": "q", "state": "open"}, {"id": "p", "chat_id": 12, "name": "Project"}, None)
assert not {"question", "options"} & narrow.keys(), "unknown display fields are omitted, never blanked"
assert not {"question", "options", "option_details", "stake"} & narrow.keys(), \
"unknown display fields are omitted, never blanked"
def test_project_room_quiz_row_carries_the_wait_facts(tmp_path):
@ -260,3 +272,87 @@ def test_project_room_quiz_row_carries_the_wait_facts(tmp_path):
set_owner_wait(tmp_path, "t1", {"quiz_id": "q2", "wait_id": "w2", "state": "waiting"})
room = json.loads(_assemble_history_response(tmp_path, project["chat_id"], 10, 0))["messages"]
assert room[0]["quiz"]["owner_wait_state"] == "resumed"
from tests.test_contracts import REPO_ROOT, _dict_type_discriminator, _dict_literal_keys
def _string_tuple(node: ast.AST) -> tuple[str, ...] | None:
if isinstance(node, ast.Tuple) and node.elts and all(
isinstance(elt, ast.Constant) and isinstance(elt.value, str) for elt in node.elts):
return tuple(elt.value for elt in node.elts)
return None
def _function_node(source_path: pathlib.Path, name: str) -> ast.AST:
tree = ast.parse(source_path.read_text(encoding="utf-8"))
found = [node for node in ast.walk(tree)
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name]
assert len(found) == 1, f"expected one {name} in {source_path.name}"
return found[0]
def test_project_question_pointer_display_fields_share_one_contract():
"""Main mirrors a Project question as the Project's own form, so each pointer producer and
both ABI mirrors carry the same display fields, ``option_details`` and ``stake`` included.
The live frame (``message_bus.send_quiz``) copies them in a key loop and the census
(``state._task_activity_facts``) in a key tuple, neither of which the envelope literal scan
above can see; the browser keeps what it merges in ``chat_decision.js`` MIRROR_FIELDS."""
from typing import get_args, get_origin, get_type_hints
from ouroboros.gateway.contracts import ChatOutbound
from ouroboros.project_dialogue import project_question_pointer
hints = get_type_hints(ChatOutbound, include_extras=True)
assert "option_details" in hints and "stake" in hints
# Both optional: a legacy label-only ask retained no details, and most asks name no stake.
for field in ("option_details", "stake"):
assert str(hints[field]).startswith(("typing.NotRequired[", "typing_extensions.NotRequired[")), hints[field]
details = get_args(hints["option_details"])[0]
assert get_origin(details) is list and get_args(details) == (str,)
assert get_args(hints["stake"]) == (str,)
api_types = (REPO_ROOT / "web" / "modules" / "api_types.js").read_text(encoding="utf-8")
typedef = re.search(r"@typedef \{Object\} ChatOutbound\b(?P<body>.*?)\n \*/", api_types, re.S)
assert typedef, "api_types.js missing ChatOutbound"
assert re.search(r"@property \{string\[\]=\} option_details\b", typedef.group("body"))
assert re.search(r"@property \{string=\} stake\b", typedef.group("body"))
mirror = re.search(r"const MIRROR_FIELDS = \[([^\]]*)\]",
(REPO_ROOT / "web" / "modules" / "chat_decision.js").read_text(encoding="utf-8"))
assert mirror, "chat_decision.js MIRROR_FIELDS moved"
browser_fields = set(re.findall(r"'([a-z_]+)'", mirror.group(1)))
assert {"option_details", "stake"} <= browser_fields
assert browser_fields <= set(hints), sorted(browser_fields - set(hints))
# 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}
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"
# 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")
frames = [node for node in ast.walk(send_quiz) if isinstance(node, ast.Dict)
and _dict_type_discriminator(node) == "chat"]
assert len(frames) == 1, "send_quiz builds one pointer frame"
literal, unknown = _dict_literal_keys(frames[0])
assert not unknown
copied = [_string_tuple(node.iter) for node in ast.walk(send_quiz)
if isinstance(node, ast.For) and _string_tuple(node.iter) and "recommended_index" in _string_tuple(node.iter)]
assert len(copied) == 1, "send_quiz copies the pointer's display fields in one key loop"
live = literal | set(copied[0])
assert {"option_details", "stake"} <= set(copied[0])
assert browser_fields <= live, sorted(browser_fields - live)
assert live <= set(hints), sorted(live - set(hints))
# Census: the quiz facts the stat-keyed memo keeps for required_question.
facts = _function_node(REPO_ROOT / "ouroboros" / "gateway" / "state.py", "_task_activity_facts")
census = [_string_tuple(gen.iter) for node in ast.walk(facts) if isinstance(node, ast.DictComp)
for gen in node.generators if _string_tuple(gen.iter) and "question" in _string_tuple(gen.iter)]
assert len(census) == 1, "_task_activity_facts keeps the quiz display fields in one key tuple"
quiz_sourced = browser_fields - {"project_name"}
assert quiz_sourced <= set(census[0]), sorted(quiz_sourced - set(census[0]))