fix(chat): the pointer row is complete, live wait facts win, one status word leads

Roast findings (codex gpt-6-astra) folded in:

- The pointer row carries the question, option labels, recorded answer and the
  wait facts from history, the live delivery and the activity census
  (`project_question_pointer`, `owner_wait_projection`; both contract mirrors),
  so Main paints it from the row alone. The IntersectionObserver hydration, the
  settled-source cache and the Retry state are gone: freshness is the ordinary
  history reconciliation plus the `quiz_state` frame, and task detail is read
  only to open the original form.
- Lifecycle observation precedence: once a live frame closed a wait, an older
  history row or a detail read begun before it cannot reopen «Waiting»; an
  unavailable row keeps what is known; a settled question never reopens. The
  production timeout frame (`wait_for_answer:false`, no `wait_ended_at`) is the
  shape the tests use.
- Parity: history attaches the task's wait record to the Project room's quiz rows
  (a wait the owner resumed by ordinary input leaves no frame behind), the quiz
  card reads those facts, and the parity fixture now pins the rows Python emits
  against what the browser reads from them.
- Wording leads with one word — «Waiting for your answer» / «Unanswered · …» /
  «You answered» / «Replaced by a newer question» / «Status unavailable» — with
  three action labels; status and source lines use meta ink (DESIGN: the owner
  reads them to act). The wait-ended line names the default path the task took
  and that silence was not consent; a late answer's toast says where it went.
- Previews bound the option and the comment separately and never cut for less
  than the marker costs.
- Module map row for `question_presentation.js`; DEVELOPMENT 11 points at the
  ARCHITECTURE data flow instead of restating it; a stale comment in
  `owner_quiz.py`; `contracts.py` pays its 1600-line gate down by compacting
  five comment blocks.
This commit is contained in:
Ouroboros 2026-09-17 19:30:17 +03:00
parent 1fdfc722b5
commit eda1181f46
20 changed files with 611 additions and 340 deletions

View file

@ -41,7 +41,10 @@ def test_real_escalation_bridge_history_detail_and_answer(tmp_path, monkeypatch)
assert [row["type"] for row in frames] == ["quiz", "chat"]
pointer = frames[1]
assert pointer["system_type"] == "project_question_pointer" and pointer["chat_id"] == 1
assert not {"options", "question", "comment", "answered_index"} & pointer.keys()
# 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"
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"]
@ -58,7 +61,9 @@ def test_real_escalation_bridge_history_detail_and_answer(tmp_path, monkeypatch)
{"request_id": "answer-1", "decision_id": f"quiz:task-1:{qid}", "option_index": 1})
assert answered.status_code == 200, answered.text
main = json.loads(_assemble_history_response(tmp_path, 1, 10, 0))["messages"]
assert main[0]["quiz_state"] == "answered"
assert main[0]["quiz_state"] == "answered" and main[0]["answered_index"] == 1
assert main[0]["question"] == "Which storage?" and main[0]["options"] == ["Local", "Shared"]
assert main[0]["text"] == "You answered in Question Project"
assert reconcile_terminal(tmp_path, "task-1") == []
assert quiz_states(tmp_path, "task-1")[qid]["option_details"][1] == "Multiple writers over network"
@ -100,12 +105,13 @@ def test_activity_question_uses_same_memo_and_preserves_wait_semantics(tmp_path,
monkeypatch.setattr(utils, "read_json_dict", lambda path: (reads.append(str(path)), real(path))[1])
rows = gs._chat_activities_snapshot_safe(tmp_path, direct_turns=[])
assert rows[0]["required_question"]["quiz_state"] == "open"
assert rows[0]["required_question"]["text"] == "Waiting for your answer in Waiting Project"
assert reads.count(str(tmp_path / "task_results/t1.json")) == 1
gs._chat_activities_snapshot_safe(tmp_path, direct_turns=[])
assert reads.count(str(tmp_path / "task_results/t1.json")) == 1
set_owner_wait(tmp_path, "t1", {"quiz_id": "q1", "wait_id": "w1", "state": "resumed"}, "w1")
rows = gs._chat_activities_snapshot_safe(tmp_path, direct_turns=[])
assert rows[0]["required_question"]["text"] == "Task continued — you can still answer in Waiting Project"
assert rows[0]["required_question"]["text"] == "Unanswered · the task continued; an answer is still accepted in Waiting Project"
assert quiz_states(tmp_path, "t1")["q1"]["state"] == "open"
# A wait that ended on its OWN bound resumed without an answer, so the
# question is still wanted: no new wait state, just the additive reason.
@ -114,7 +120,7 @@ def test_activity_question_uses_same_memo_and_preserves_wait_semantics(tmp_path,
gs._FINALIZING_MEMO.clear()
rows = gs._chat_activities_snapshot_safe(tmp_path, direct_turns=[])
pointer = rows[0]["required_question"]
assert pointer["text"] == "Task continued — you can still answer in Waiting Project"
assert pointer["text"] == "Unanswered · the task continued; an answer is still accepted in Waiting Project"
assert pointer["owner_wait_resume_reason"] == "timeout"
@ -131,15 +137,47 @@ def test_details_are_immutable_optional_and_length_checked(tmp_path):
def test_question_presentation_shared_fixture():
"""Both sides of the parity fixture: Python emits exactly the fixture row for each case, and the
browser (web/tests/question_presentation.test.js) reads the fixture status out of that row."""
from pathlib import Path
from ouroboros.project_dialogue import project_question_pointer
cases = json.loads((Path(__file__).resolve().parents[1] /
'web/tests/fixtures/question_presentation_parity.json').read_text())
for row in cases:
keys = ("quiz_state", "owner_wait_state", "owner_wait_resume_reason", "wait_for_answer",
"wait_ended_at", "answered_index", "comment", "source_status")
for case in cases:
block = {"quiz_id": "q", **case["block"]} if case["block"] is not None else None
wait = {"quiz_id": "q", **case["owner_wait"]} if case["owner_wait"] is not None else None
pointer = project_question_pointer(
{'task_id': 'task', 'quiz': {'quiz_id': 'q', 'wait_for_answer': True}},
{'quiz_id': 'q', **row}, {'id': 'p', 'chat_id': 12, 'name': 'Project'},
{'quiz_id': 'q', 'state': row.get('owner_wait_state', ''),
'resume_reason': row.get('owner_wait_resume_reason', '')})
assert pointer['text'] == row['status'] + ' in Project'
{"task_id": "task", "text": "Which?", "quiz": {"quiz_id": "q", "wait_for_answer": True, "options": ["a", "b"]}},
block, {"id": "p", "chat_id": 12, "name": "Project"}, wait)
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"]
def test_project_room_quiz_row_carries_the_wait_facts(tmp_path):
"""A wait the owner resumed by ordinary input leaves no frame behind: the replayed card
reads the task's wait record like the Main pointer does."""
from ouroboros.utils import append_jsonl
project = create_project(tmp_path, "resumed-project", name="Resumed Project")
write_task_result(tmp_path, "t1", STATUS_RUNNING, project_id=project["id"], chat_id=project["chat_id"])
record_asked(tmp_path, "t1", quiz_id="q1", question="Which?", options=["a", "b"], wait_for_answer=True)
append_jsonl(tmp_path / "logs/chat.jsonl", {"type": "quiz", "direction": "out", "chat_id": project["chat_id"],
"task_id": "t1", "ts": "2026-09-16T00:00:00Z", "text": "Which?",
"quiz": {"quiz_id": "q1", "options": ["a", "b"], "wait_for_answer": True}})
room = json.loads(_assemble_history_response(tmp_path, project["chat_id"], 10, 0))["messages"]
assert "owner_wait_state" not in room[0]["quiz"]
set_owner_wait(tmp_path, "t1", {"quiz_id": "q1", "wait_id": "w1", "state": "waiting"})
room = json.loads(_assemble_history_response(tmp_path, project["chat_id"], 10, 0))["messages"]
assert room[0]["quiz"]["owner_wait_state"] == "waiting"
set_owner_wait(tmp_path, "t1", {"quiz_id": "q1", "wait_id": "w1", "state": "resumed"})
room = json.loads(_assemble_history_response(tmp_path, project["chat_id"], 10, 0))["messages"]
assert room[0]["quiz"]["owner_wait_state"] == "resumed" and room[0]["quiz"]["wait_for_answer"] is True
main = json.loads(_assemble_history_response(tmp_path, 1, 10, 0))["messages"]
assert main[0]["text"] == "Unanswered · the task continued; an answer is still accepted in Resumed Project"
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"