mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 12:18:39 +00:00
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.
188 lines
12 KiB
Python
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"
|