mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 04:07:04 +00:00
Main mirrored only blocking Project questions, each as a tall amber System card that stayed that tall forever. Three questions asked in a row filled the screen long after they were answered, while questions the task passed under an assumption never reached Main at all. Now the row's size follows the owner's attention. Every Project question projects into Main (history, the live frame and the activity census share one producer). In every settled or passed state it is one line in the project chip's language: status, then the recorded answer or the assumption the task continues under, then the question as context, the Project and the time. The whole line is one control that opens the exact question. Only while the task actually waits does the row grow into a card with the whole question, the option labels as buttons and one "Details and own answer" action; a press answers through the existing decision ingress and the card folds back into its line. Own words, option details and the stake stay in the Project form. The view is a pure function of the question's recorded facts: no timers, no grouping, no new store. The pointer row gains two additive fields, `assumption` and `recommended_index` (ChatOutbound and its JS mirror, the live frame and the census whitelist). The "wait moved on to another quiz" inference now resumes only a question the task actually waited on, on both sides. An answer from Main settles through the same observation a quiz_state frame uses, so a lost frame plus a stale open snapshot cannot reopen it. The dead resetViews export is removed. Tests: web/tests/question_rows.test.js (realistic burst, every state, one-touch answer, races, focus), the shared DOM stub moves to chat_decision_fixture.js, backend projection of an optional question beside a required one, the browser scenario at 1100 and 390 px, and the real-server journey answering from Main. Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
257 lines
17 KiB
Python
257 lines
17 KiB
Python
"""Project questions share their durable ask with every display lens."""
|
|
import json
|
|
import os
|
|
|
|
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_and_dedup(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_an_optional_question_projects_into_main_with_its_assumption_and_never_reads_as_resumed(tmp_path, monkeypatch):
|
|
"""Every Project question is one Main row, whether or not the task waits on it. The optional
|
|
one carries the assumption the task continues under and the asker's recommendation; a newer
|
|
REQUIRED wait of the same task must not repaint it as a wait that ended, because it never
|
|
waited (only a question the task waited on can have been resumed)."""
|
|
from supervisor import message_bus, state
|
|
|
|
project = create_project(tmp_path, "mixed-project", name="Mixed Project")
|
|
write_task_result(tmp_path, "t1", STATUS_RUNNING, project_id=project["id"], chat_id=project["chat_id"])
|
|
ctx = _tool_ctx(tmp_path, task_id="t1", chat_id=project["chat_id"])
|
|
ctx.owner_wait_callback = lambda *_: None
|
|
assert _escalate(ctx, question="Which format?", assumption="WebP meanwhile",
|
|
options=[{"label": "PNG"}, {"label": "WebP", "recommended": True}]).startswith("OK:")
|
|
optional = ctx.pending_events[0]
|
|
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: optional[key] for key in (
|
|
"quiz_id", "question", "options", "stake", "assumption", "state", "task_id")})[0]
|
|
assert [row["type"] for row in frames] == ["quiz", "chat"]
|
|
live = frames[1]
|
|
assert live["system_type"] == "project_question_pointer" and live["chat_id"] == 1
|
|
assert live["assumption"] == "WebP meanwhile" and live["recommended_index"] == 1
|
|
assert live["options"] == ["PNG", "WebP"] and "wait_for_answer" not in live
|
|
assert live["content"] == "Unanswered · an answer is still accepted in Mixed Project"
|
|
|
|
# The same task now waits on a newer required question.
|
|
assert _escalate(ctx, question="Publish?", wait_for_answer=True,
|
|
options=[{"label": "Yes", "recommended": True}, {"label": "No"}]).startswith("OK:")
|
|
required = ctx.pending_events[1]
|
|
assert bridge.send_quiz(project["chat_id"], **{key: required[key] for key in (
|
|
"quiz_id", "question", "options", "stake", "assumption", "state", "task_id", "wait_for_answer")})[0]
|
|
set_owner_wait(tmp_path, "t1", {"quiz_id": required["quiz_id"], "wait_id": "w1", "state": "waiting"})
|
|
main = {row["quiz_id"]: row for row in json.loads(_assemble_history_response(tmp_path, 1, 10, 0))["messages"]}
|
|
assert set(main) == {optional["quiz_id"], required["quiz_id"]}
|
|
calm, waiting = main[optional["quiz_id"]], main[required["quiz_id"]]
|
|
assert "owner_wait_state" not in calm and "wait_for_answer" not in calm
|
|
assert calm["assumption"] == "WebP meanwhile" and calm["recommended_index"] == 1
|
|
assert calm["text"] == "Unanswered · an answer is still accepted in Mixed Project"
|
|
assert waiting["owner_wait_state"] == "waiting" and waiting["recommended_index"] == 0
|
|
assert waiting["text"] == "Waiting for your answer in Mixed Project"
|
|
# The Project room's own card of the optional question reads the same facts.
|
|
room = {row["quiz"]["quiz_id"]: row["quiz"] for row in json.loads(
|
|
_assemble_history_response(tmp_path, project["chat_id"], 10, 0))["messages"] if row.get("msg_type") == "quiz"}
|
|
assert "owner_wait_state" not in room[optional["quiz_id"]]
|
|
|
|
|
|
@pytest.mark.parametrize("same_timestamp", [False, True])
|
|
def test_activity_question_uses_same_memo_and_preserves_wait_semantics(tmp_path, monkeypatch, same_timestamp):
|
|
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,
|
|
recommended_index=0)
|
|
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"]
|
|
# A card painted from the census alone still badges the recommended option (index zero included).
|
|
assert rows[0]["required_question"]["recommended_index"] == 0
|
|
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
|
|
path = tmp_path / "task_results/t1.json"
|
|
before = path.stat()
|
|
set_owner_wait(tmp_path, "t1", {"quiz_id": "q1", "wait_id": "w1", "state": "resumed"}, "w1")
|
|
if same_timestamp:
|
|
# Atomic replacement within one filesystem timestamp tick can retain
|
|
# both mtime and size: waiting/resumed have the same serialized length.
|
|
os.utime(path, ns=(before.st_atime_ns, before.st_mtime_ns))
|
|
after = path.stat()
|
|
assert after.st_ino != before.st_ino
|
|
assert (after.st_mtime_ns, after.st_size) == (before.st_mtime_ns, before.st_size)
|
|
previous_reads = reads.count(str(path))
|
|
rows = gs._chat_activities_snapshot_safe(tmp_path, direct_turns=[])
|
|
assert rows[0]["required_question"]["owner_wait_state"] == "resumed"
|
|
assert rows[0]["required_question"]["text"] == "Unanswered · the task continued; an answer is still accepted in Waiting Project"
|
|
assert reads.count(str(path)) == previous_reads + 1
|
|
gs._chat_activities_snapshot_safe(tmp_path, direct_turns=[])
|
|
assert reads.count(str(path)) == previous_reads + 1
|
|
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"
|