ouroboros/tests/test_project_question_pointer.py
Ouroboros 2577360dcd test(chat): read the parity fixture as UTF-8
The Windows full-test job decodes an encoding-less read_text() as cp1252, which mangles the
middle dot of the lifecycle words in question_presentation_parity.json and fails the Python
half of the parity test. The fixture is now read with an explicit UTF-8 encoding.
2026-09-17 21:27:05 +03:00

188 lines
12 KiB
Python

"""Required Project questions share their durable ask with every display lens."""
import json
import pytest
from ouroboros.gateway.history import _assemble_history_response
from ouroboros.owner_quiz import quiz_states, record_asked, record_answered, reconcile_terminal
from ouroboros.owner_wait import set_owner_wait
from ouroboros.projects_registry import create_project
from ouroboros.task_results import write_task_result, STATUS_RUNNING
from tests.test_quiz_answer import _tool_ctx, _escalate, _decision_app, _post
def test_real_escalation_bridge_history_detail_and_answer(tmp_path, monkeypatch):
from supervisor import message_bus, state
from ouroboros.gateway import tasks
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.testclient import TestClient
project = create_project(tmp_path, "question-project", name="Question Project")
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,
options=[{"label": "Local", "detail": "Single-machine files"},
{"label": "Shared", "detail": "Multiple writers over network"}])
assert result.startswith("OK:")
event = ctx.pending_events[0]
qid = event["quiz_id"]
assert quiz_states(tmp_path, "task-1")[qid]["option_details"] == [
"Single-machine files", "Multiple writers over network"]
monkeypatch.setattr(message_bus, "DATA_DIR", tmp_path)
state.init(tmp_path, 100)
frames = []
bridge = message_bus.LocalChatBridge({})
bridge._broadcast_fn = frames.append
monkeypatch.setattr(message_bus, "get_bridge", lambda: bridge)
assert bridge.send_quiz(project["chat_id"], **{key: event[key] for key in (
"quiz_id", "question", "options", "stake", "assumption", "state", "task_id", "wait_for_answer")})[0]
assert [row["type"] for row in frames] == ["quiz", "chat"]
pointer = frames[1]
assert pointer["system_type"] == "project_question_pointer" and pointer["chat_id"] == 1
# 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"]
assert len(main) == 1 and main[0]["quiz_id"] == qid
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"
monkeypatch.setattr(tasks, "request_drive_root", lambda request: tmp_path)
app = Starlette(routes=[Route('/api/tasks/{task_id}', tasks.api_task_get)])
detail = TestClient(app).get('/api/tasks/task-1').json()
assert detail["task_id"] == "task-1" and detail["project_id"] == project["id"]
assert detail["owner_quiz"][qid]["option_details"] == quiz_states(tmp_path, "task-1")[qid]["option_details"]
answered = _post(_decision_app(tmp_path, monkeypatch, live_task={"id": "task-1", "chat_id": project["chat_id"]}),
{"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" 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"
def test_pointer_quota_dedup_and_optional_filter(tmp_path):
from ouroboros.utils import append_jsonl
project = create_project(tmp_path, "many-questions", name="Many Questions")
for i in range(8):
qid = f"q{i}"
record_asked(tmp_path, "t1", quiz_id=qid, question="?", options=["a", "b"], wait_for_answer=True)
row = {"type": "quiz", "direction": "out", "chat_id": project["chat_id"], "task_id": "t1",
"ts": f"2026-09-09T00:00:0{i}Z", "text": "?", "quiz": {"quiz_id": qid,
"options": ["a", "b"], "wait_for_answer": True}}
append_jsonl(tmp_path / "logs/chat.jsonl", row)
append_jsonl(tmp_path / "logs/chat.jsonl", row)
append_jsonl(tmp_path / "logs/chat.jsonl", {**row, "quiz": {**row["quiz"], "quiz_id": "optional", "wait_for_answer": False}})
messages = json.loads(_assemble_history_response(tmp_path, 1, 3, 0))["messages"]
assert len(messages) == 3
assert len({(row["task_id"], row["quiz_id"]) for row in messages}) == 3
assert all(row["system_type"] == "project_question_pointer" for row in messages)
def test_activity_question_uses_same_memo_and_preserves_wait_semantics(tmp_path, monkeypatch):
from ouroboros.gateway import state as gs
from ouroboros import utils
from supervisor import queue
project = create_project(tmp_path, "waiting-project", name="Waiting Project")
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)
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"]}}})
gs._FINALIZING_MEMO.clear()
reads = []
real = utils.read_json_dict
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"
# 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 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"] == "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.
set_owner_wait(tmp_path, "t1", {"quiz_id": "q1", "wait_id": "w1", "state": "resumed",
"resume_reason": "timeout"}, "w1")
gs._FINALIZING_MEMO.clear()
rows = gs._chat_activities_snapshot_safe(tmp_path, direct_turns=[])
pointer = rows[0]["required_question"]
assert pointer["text"] == "Unanswered · the task continued; an answer is still accepted in Waiting Project"
assert pointer["owner_wait_resume_reason"] == "timeout"
def test_details_are_immutable_optional_and_length_checked(tmp_path):
record_asked(tmp_path, "t", quiz_id="new", question="?", options=["a", "b"], option_details=["A", "B"])
record_asked(tmp_path, "t", quiz_id="new", question="?", options=["a", "b"], option_details=["changed", "changed"])
record_answered(tmp_path, "t", quiz_id="new", option_index=1, request_id="r")
record_asked(tmp_path, "t", quiz_id="old", question="?", options=["a", "b"])
reconcile_terminal(tmp_path, "t")
assert quiz_states(tmp_path, "t")["new"]["option_details"] == ["A", "B"]
assert "option_details" not in quiz_states(tmp_path, "t")["old"]
with pytest.raises(ValueError):
record_asked(tmp_path, "t", quiz_id="bad", question="?", options=["a", "b"], option_details=["A"])
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(encoding="utf-8"))
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", "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"]
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"
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"