mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-02 19:58:46 +00:00
Keep late quiz answers in the owner's words and frame own-word answers truthfully
The answer frame for a quiz answered without choosing an offered option no
longer claims the owner "rejected all offered options"; it now says the owner
answered in their own words without choosing an offered option and quotes the
answer verbatim. The frame builder moves to ouroboros/owner_quiz.py
(quiz_answer_frame) so the decision ingress and the late-answer delivery share
one builder; the gateway keeps a thin alias.
A late answer (the asking task already finished) is now delivered into the
card's chat as the owner's own words: the canonical chat row, the queued
message and the echoed bubble carry the verbatim comment, or the pressed option
as "{n}. {label}" when there is no comment, never the English host frame. The
typed task_metadata.late_answer provenance, which was written but read nowhere,
now has consumers: a direct turn started by the late answer and a mailbox
delivery into a Project room's single live root both rebuild the full frame
(asked/answered stamps, question, verbatim answer) from the stored quiz block
for the model and record the owner directive with that frame. The mailbox
entry and its drain projection carry late_answer additively. When the block
cannot be read, the model receives the owner's words plus one host line naming
the quiz and saying the card could not be read.
A retry of a late answer that was accepted earlier under the old frame text
rejoins that delivery instead of failing on the text mismatch.
This commit is contained in:
parent
55ad78213a
commit
ee50111587
9 changed files with 435 additions and 54 deletions
|
|
@ -107,33 +107,12 @@ def _quiz_answer_frame(
|
|||
) -> str:
|
||||
"""Host-authored structural frame around the owner's VERBATIM choice.
|
||||
|
||||
The asked/answered timestamps ride inside so the MODEL judges freshness
|
||||
itself (owner decision 30=A — no host staleness verdict). With no
|
||||
``option_index`` the owner took none of the offered options and wrote
|
||||
their own answer — say exactly that, so the model never reads the free
|
||||
answer as a gloss on a chosen option."""
|
||||
options = block.get("options") if isinstance(block.get("options"), list) else []
|
||||
lines = [
|
||||
f"[Owner quiz answer] quiz {block.get('quiz_id')} — asked {block.get('asked_at')}, "
|
||||
f"answered {block.get('answered_at')}.",
|
||||
f"Question was: {block.get('question')}",
|
||||
]
|
||||
if option_index is None:
|
||||
lines.append(
|
||||
"The owner rejected all offered options and answered verbatim: "
|
||||
f"{comment}"
|
||||
)
|
||||
else:
|
||||
label = str(options[option_index]) if 0 <= option_index < len(options) else ""
|
||||
lines.append(f"The owner chose option {option_index + 1}: {label}")
|
||||
if comment:
|
||||
lines.append(f"Owner comment (verbatim): {comment}")
|
||||
if str(block.get("assumption") or "") and not block.get("wait_for_answer"):
|
||||
lines.append(
|
||||
f"You continued under the assumption: {block.get('assumption')} — "
|
||||
"judge yourself whether work has moved past the answered fork."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
Thin alias of the ONE shared builder ``owner_quiz.quiz_answer_frame``, so
|
||||
the live mailbox control here and the late-answer delivery in the drain
|
||||
render the same words."""
|
||||
from ouroboros.owner_quiz import quiz_answer_frame
|
||||
|
||||
return quiz_answer_frame(block, option_index, comment)
|
||||
|
||||
|
||||
def _refused(message: str, status: int, **extra: Any) -> Tuple[int, Dict[str, Any]]:
|
||||
|
|
@ -224,8 +203,14 @@ def _forward_late_quiz_answer(
|
|||
is the acceptance receipt and whose ``client_message_id`` is the
|
||||
idempotency key (a crash after acceptance never authorizes another
|
||||
enqueue), so a retry of this request re-enters the same delivery instead of
|
||||
duplicating it. The text is the same full ``_quiz_answer_frame`` the live
|
||||
mailbox control carries — nothing is trimmed.
|
||||
duplicating it.
|
||||
|
||||
The canonical chat row and the owner's bubble carry the HUMAN's words only
|
||||
(``owner_quiz.late_answer_owner_text``: the verbatim comment, else the
|
||||
pressed option as ``{n}. {label}``), never the host frame. The typed
|
||||
``late_answer`` provenance rides the message's metadata; the receiving turn
|
||||
rebuilds the FULL frame from the stored block for the model
|
||||
(``owner_quiz.late_answer_model_text``), so the model still reads the card.
|
||||
|
||||
Disclosed property: in a PROJECT room that currently has exactly one live
|
||||
steerable root task, the ordinary routing delivers this message into THAT
|
||||
|
|
@ -237,21 +222,32 @@ def _forward_late_quiz_answer(
|
|||
chat_id, reason = _late_answer_destination(drive_root, task_id, block)
|
||||
if chat_id is None:
|
||||
return False, reason
|
||||
index = block.get("answered_index")
|
||||
text = _quiz_answer_frame(
|
||||
block, index if isinstance(index, int) else None,
|
||||
str(block.get("comment") or ""),
|
||||
)
|
||||
from ouroboros.owner_quiz import late_answer_owner_text
|
||||
|
||||
text = late_answer_owner_text(block)
|
||||
if not text:
|
||||
# An answered block always has a comment or a valid index; a block
|
||||
# that has neither cannot be spoken as the owner's words.
|
||||
return False, "answer_unreadable"
|
||||
client_message_id = f"quiz_late_answer:{task_id}:{quiz_id}"
|
||||
bridge = message_bus.get_bridge()
|
||||
row, rejoined = message_bus.accept_local_message(
|
||||
bridge, drive_root, text,
|
||||
chat_id=chat_id, user_id=1, source=str(source or "web"),
|
||||
client_message_id=client_message_id,
|
||||
# Provenance rides its OWN field; the real transport (the web card, or
|
||||
# the skill that relayed the owner's tap) is the message's source.
|
||||
task_metadata={"late_answer": {"task_id": task_id, "quiz_id": quiz_id}},
|
||||
)
|
||||
try:
|
||||
row, rejoined = message_bus.accept_local_message(
|
||||
bridge, drive_root, text,
|
||||
chat_id=chat_id, user_id=1, source=str(source or "web"),
|
||||
client_message_id=client_message_id,
|
||||
# Provenance rides its OWN field; the real transport (the web card, or
|
||||
# the skill that relayed the owner's tap) is the message's source.
|
||||
task_metadata={"late_answer": {"task_id": task_id, "quiz_id": quiz_id}},
|
||||
)
|
||||
except ValueError:
|
||||
# This id was already accepted, but under other bytes: a delivery
|
||||
# accepted before the row carried only the owner's words (it carried
|
||||
# the host frame then). It WAS delivered once; a retry rejoins it
|
||||
# instead of failing forever or enqueueing a second owner turn.
|
||||
if message_bus.accepted_chat_message(drive_root, chat_id, client_message_id) is None:
|
||||
raise
|
||||
return True, ""
|
||||
if not rejoined:
|
||||
# The named ingress accepts and enqueues but does not echo; give the
|
||||
# owner the SAME user bubble their own typing produces (a rejoin
|
||||
|
|
|
|||
|
|
@ -197,10 +197,21 @@ def _drain_incoming_messages(
|
|||
)
|
||||
acknowledge_transcript_entry(drive_root, task_id, entry)
|
||||
continue
|
||||
# A LATE quiz answer: the owner's row and this entry's text are the
|
||||
# owner's own words; the model reads (and the owner corpus keeps) the
|
||||
# FULL card frame rebuilt from the stored block on the canonical root.
|
||||
model_msg = dmsg
|
||||
if entry.get("late_answer") is not None:
|
||||
from ouroboros.owner_quiz import late_answer_model_text
|
||||
|
||||
model_msg = late_answer_model_text(
|
||||
str(getattr(owner_ctx, "budget_drive_root", "") or "") or drive_root,
|
||||
entry.get("late_answer"), dmsg,
|
||||
)
|
||||
_loop()._record_owner_directive(
|
||||
owner_ctx,
|
||||
source="owner_mailbox",
|
||||
content=dmsg,
|
||||
content=model_msg,
|
||||
msg_id=str(entry.get("msg_id") or ""),
|
||||
)
|
||||
_stamp_owner_delivery(
|
||||
|
|
@ -213,7 +224,7 @@ def _drain_incoming_messages(
|
|||
from ouroboros.client_surface import noted_owner_text
|
||||
|
||||
_loop()._append_or_merge_user_message(
|
||||
messages, _loop()._owner_marked_content(noted_owner_text(owner_ctx, entry, dmsg)),
|
||||
messages, _loop()._owner_marked_content(noted_owner_text(owner_ctx, entry, model_msg)),
|
||||
slot=owner_ctx,
|
||||
)
|
||||
acknowledge_transcript_entry(drive_root, task_id, entry)
|
||||
|
|
|
|||
|
|
@ -206,6 +206,7 @@ def write_owner_message(
|
|||
client_surface: Optional[Dict[str, Any]] = None,
|
||||
attachment_manifest: Optional[List[Dict[str, Any]]] = None,
|
||||
client_message_id: str = "",
|
||||
late_answer: Optional[Dict[str, Any]] = None,
|
||||
) -> bool:
|
||||
"""Write an owner message or typed control entry to a task's mailbox.
|
||||
|
||||
|
|
@ -213,6 +214,11 @@ def write_owner_message(
|
|||
(additively, like ``client_surface``) only when the writer knows it
|
||||
STRUCTURALLY — never parsed back out of ``msg_id``, whose shape is a
|
||||
transport key each producer composes for its own dedupe.
|
||||
|
||||
``late_answer`` is the typed ``{task_id, quiz_id}`` provenance of an owner
|
||||
message that answers a finished task's quiz card (stored additively when
|
||||
valid): the drain rebuilds the card's frame for the model from it, while
|
||||
``text`` stays the owner's own words.
|
||||
"""
|
||||
path = _mailbox_path(drive_root, task_id)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
|
@ -224,6 +230,11 @@ def write_owner_message(
|
|||
}
|
||||
if str(client_message_id or ""):
|
||||
entry["client_message_id"] = str(client_message_id)
|
||||
from ouroboros.owner_quiz import late_answer_ref
|
||||
|
||||
late_ref = late_answer_ref(late_answer)
|
||||
if late_ref is not None:
|
||||
entry["late_answer"] = late_ref
|
||||
if isinstance(client_surface, dict) and client_surface:
|
||||
# Owner Surface Fact (additive, like ``ts``): which client surface sent
|
||||
# this follow-up, so the loop can note a mid-task device change.
|
||||
|
|
@ -680,6 +691,10 @@ def drain_owner_entries(
|
|||
# out here is a written fact nobody can read.
|
||||
if str(entry.get("client_message_id") or ""):
|
||||
drained["client_message_id"] = str(entry["client_message_id"])
|
||||
# Same for a late quiz answer's typed provenance: the drain
|
||||
# rebuilds the card frame for the model from it.
|
||||
if isinstance(entry.get("late_answer"), dict):
|
||||
drained["late_answer"] = dict(entry["late_answer"])
|
||||
if isinstance(entry.get("attachment_manifest"), list):
|
||||
drained["attachment_manifest"] = [
|
||||
dict(item) for item in entry["attachment_manifest"]
|
||||
|
|
|
|||
|
|
@ -336,3 +336,106 @@ def quiz_states(drive_root: Any, task_id: str) -> Dict[str, Dict[str, Any]]:
|
|||
if not isinstance(quizzes, dict):
|
||||
return {}
|
||||
return {str(k): dict(v) for k, v in quizzes.items() if isinstance(v, dict)}
|
||||
|
||||
|
||||
def quiz_answer_frame(
|
||||
block: Dict[str, Any], option_index: Optional[int], comment: str,
|
||||
) -> str:
|
||||
"""Host-authored structural frame around the owner's VERBATIM choice.
|
||||
|
||||
The ONE frame builder shared by the live mailbox control (the decision
|
||||
ingress) and the late-answer delivery (the drain / direct turn), so the
|
||||
model reads the same words whichever way the answer arrives.
|
||||
|
||||
The asked/answered timestamps ride inside so the MODEL judges freshness
|
||||
itself (owner decision 30=A — no host staleness verdict). With no
|
||||
``option_index`` the owner took none of the offered options and wrote
|
||||
their own answer — say exactly that, without calling it a rejection the
|
||||
owner never stated, so the model never reads the free answer as a gloss on
|
||||
a chosen option."""
|
||||
options = block.get("options") if isinstance(block.get("options"), list) else []
|
||||
lines = [
|
||||
f"[Owner quiz answer] quiz {block.get('quiz_id')} — asked {block.get('asked_at')}, "
|
||||
f"answered {block.get('answered_at')}.",
|
||||
f"Question was: {block.get('question')}",
|
||||
]
|
||||
if option_index is None:
|
||||
lines.append(
|
||||
"The owner answered in their own words without choosing an offered "
|
||||
f"option. Verbatim: {comment}"
|
||||
)
|
||||
else:
|
||||
label = str(options[option_index]) if 0 <= option_index < len(options) else ""
|
||||
lines.append(f"The owner chose option {option_index + 1}: {label}")
|
||||
if comment:
|
||||
lines.append(f"Owner comment (verbatim): {comment}")
|
||||
if str(block.get("assumption") or "") and not block.get("wait_for_answer"):
|
||||
lines.append(
|
||||
f"You continued under the assumption: {block.get('assumption')} — "
|
||||
"judge yourself whether work has moved past the answered fork."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def recorded_answer_frame(block: Dict[str, Any]) -> str:
|
||||
"""The frame of an ANSWERED block, read from what the projection recorded."""
|
||||
index = block.get("answered_index")
|
||||
return quiz_answer_frame(
|
||||
block, index if isinstance(index, int) and not isinstance(index, bool) else None,
|
||||
str(block.get("comment") or ""),
|
||||
)
|
||||
|
||||
|
||||
def late_answer_owner_text(block: Dict[str, Any]) -> str:
|
||||
"""What the HUMAN said on the card, for the owner's own chat row and bubble.
|
||||
|
||||
The verbatim comment when the owner wrote one; otherwise the pressed option
|
||||
exactly as the button showed it, ``{n}. {label}``. Never the host frame:
|
||||
the bubble is the owner's words, the frame is for the model."""
|
||||
comment = str(block.get("comment") or "")
|
||||
if comment.strip():
|
||||
return comment
|
||||
options = block.get("options") if isinstance(block.get("options"), list) else []
|
||||
index = block.get("answered_index")
|
||||
if isinstance(index, int) and not isinstance(index, bool) and 0 <= index < len(options):
|
||||
return f"{index + 1}. {options[index]}"
|
||||
return ""
|
||||
|
||||
|
||||
def late_answer_ref(value: Any) -> Optional[Dict[str, str]]:
|
||||
"""The typed ``late_answer`` provenance ``{task_id, quiz_id}``, or None."""
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
task_id = str(value.get("task_id") or "").strip()
|
||||
quiz_id = str(value.get("quiz_id") or "").strip()
|
||||
if not task_id or not quiz_id:
|
||||
return None
|
||||
return {"task_id": task_id, "quiz_id": quiz_id}
|
||||
|
||||
|
||||
def late_answer_model_text(drive_root: Any, late_answer: Any, owner_text: str) -> str:
|
||||
"""The model-facing delivery of a LATE quiz answer (owner message ``owner_text``).
|
||||
|
||||
The owner's chat row carries only their own words; the receiving model
|
||||
needs the card they answered. The frame is rebuilt from the stored block on
|
||||
the canonical ``drive_root`` (the same projection the ingress recorded).
|
||||
When that block cannot be read (evicted, unanswered, or unreadable), the
|
||||
owner's words are delivered with one host line saying so — disclosed, never
|
||||
a silent loss of the card's context. A message with no valid ``late_answer``
|
||||
provenance is returned unchanged."""
|
||||
ref = late_answer_ref(late_answer)
|
||||
if ref is None:
|
||||
return owner_text
|
||||
block: Dict[str, Any] = {}
|
||||
try:
|
||||
block = quiz_states(drive_root, ref["task_id"]).get(ref["quiz_id"]) or {}
|
||||
except Exception:
|
||||
block = {}
|
||||
if isinstance(block, dict) and str(block.get("state") or "") == STATE_ANSWERED:
|
||||
return recorded_answer_frame(block)
|
||||
return (
|
||||
f"{owner_text}\n"
|
||||
f"[Host note] This owner message answers quiz {ref['quiz_id']} of task "
|
||||
f"{ref['task_id']}; that card could not be read, so only the owner's own "
|
||||
"words are shown."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -248,6 +248,9 @@ def _route_project_chat_to_running_task(
|
|||
else None
|
||||
),
|
||||
attachment_manifest=staged_manifest if staged_manifest else None,
|
||||
late_answer=(
|
||||
task_metadata.get("late_answer") if isinstance(task_metadata, dict) else None
|
||||
),
|
||||
):
|
||||
return ""
|
||||
message_written = True
|
||||
|
|
|
|||
|
|
@ -298,6 +298,15 @@ def _admit_chat_task(
|
|||
task["task_constraint"] = dict(task_constraint)
|
||||
if task_metadata:
|
||||
task["metadata"] = dict(task_metadata)
|
||||
if task_metadata.get("late_answer") is not None:
|
||||
# A LATE quiz answer: the owner's row holds only their words; the
|
||||
# model reads the card they answered, rebuilt from the stored
|
||||
# block (disclosed when unreadable) -- the one shared builder.
|
||||
from ouroboros.owner_quiz import late_answer_model_text
|
||||
|
||||
task["text"] = late_answer_model_text(
|
||||
_pool().DRIVE_ROOT, task_metadata.get("late_answer"), str(text or ""),
|
||||
)
|
||||
# The ingress-captured origin identity rides on the TASK RECORD so a
|
||||
# later post-hoc "Turn into project" reads it from the persisted
|
||||
# result instead of re-deriving identity from content.
|
||||
|
|
|
|||
|
|
@ -693,8 +693,10 @@ def test_own_answer_needs_no_option_index(tmp_path, monkeypatch):
|
|||
|
||||
entries = drain_owner_entries(tmp_path, "task-1", set())
|
||||
frame_text = [e for e in entries if e.get("kind") == KIND_QUIZ_ANSWER][0]["text"]
|
||||
assert ("The owner rejected all offered options and answered verbatim: "
|
||||
"neither — use duckdb") in frame_text
|
||||
assert ("The owner answered in their own words without choosing an offered "
|
||||
"option. Verbatim: neither — use duckdb") in frame_text
|
||||
# The host never words the free answer as a rejection the owner did not state.
|
||||
assert "rejected" not in frame_text
|
||||
assert "chose option" not in frame_text
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -100,7 +100,9 @@ def test_answers_survive_eighteen_quizzes_mailbox_gc_and_rotation(runtime):
|
|||
assert quiz["comment"] == f" Verbatim choice {index}\nsecond line "
|
||||
assert quiz["request_id"] == f"answer-{index}"
|
||||
if index % 2:
|
||||
assert "answered_index" not in quiz and "rejected all offered options" in row["text"]
|
||||
assert "answered_index" not in quiz and (
|
||||
"answered in their own words without choosing an offered option" in row["text"])
|
||||
assert "rejected" not in row["text"]
|
||||
else:
|
||||
assert quiz["answered_index"] == 0 and "chose option 1: First" in row["text"]
|
||||
assert len([frame for frame in runtime.frames if frame.get("type") == "quiz"]) == 18
|
||||
|
|
@ -134,8 +136,27 @@ def test_a_late_answer_keeps_its_evidence_row_and_also_enters_dialogue(runtime,
|
|||
if row.get("client_message_id") == "quiz_late_answer:task-quiz:late"]
|
||||
assert len(inbound) == 1 and inbound[0]["direction"] == "in"
|
||||
assert inbound[0]["chat_id"] == 1 and inbound[0]["source"] == "web"
|
||||
assert "[Owner quiz answer]" in inbound[0]["text"] and "Second" in inbound[0]["text"]
|
||||
assert [frame["role"] for frame in runtime.frames if frame.get("type") == "chat"] == ["user"]
|
||||
# The owner's row is the owner's words (the ingress strips edge whitespace
|
||||
# of every owner row), never the host frame; the frame is for the model.
|
||||
assert inbound[0]["text"] == "After the fact"
|
||||
assert "[Owner quiz answer]" not in inbound[0]["text"]
|
||||
chats = [frame for frame in runtime.frames if frame.get("type") == "chat"]
|
||||
assert [frame["role"] for frame in chats] == ["user"]
|
||||
assert chats[0]["content"] == "After the fact"
|
||||
|
||||
# Reload: history replays the late answer as an ordinary user row carrying
|
||||
# the owner's words; no path re-injects the frame into the bubble.
|
||||
from ouroboros.gateway.history import make_chat_history_endpoint
|
||||
|
||||
response = asyncio.run(make_chat_history_endpoint(runtime.root)(
|
||||
SimpleNamespace(query_params={"n_human": "100", "thread": "1"})))
|
||||
messages = json.loads(response.body)["messages"]
|
||||
replayed = [row for row in messages
|
||||
if row.get("client_message_id") == "quiz_late_answer:task-quiz:late"]
|
||||
assert len(replayed) == 1 and replayed[0]["role"] == "user"
|
||||
assert replayed[0]["text"] == "After the fact"
|
||||
assert not [row for row in messages if row.get("role") == "user"
|
||||
and "[Owner quiz answer]" in str(row.get("text") or "")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("initial_index", [0, None])
|
||||
|
|
|
|||
|
|
@ -78,17 +78,19 @@ def test_ingress_late_answer_is_accepted_and_delivered_as_an_owner_message(tmp_p
|
|||
[queued] = _inbox(bridge)
|
||||
assert queued["chat_id"] == 1 and queued["user_id"] == 1 and queued["source"] == "web"
|
||||
assert queued["client_message_id"] == source_id
|
||||
# The FULL existing answer frame, nothing trimmed; provenance is its own
|
||||
# field and the real transport's client_surface is never substituted.
|
||||
assert "[Owner quiz answer]" in queued["text"] and "Which db?" in queued["text"]
|
||||
assert "prod parity" in queued["text"] and "postgres" in queued["text"]
|
||||
# The owner's row and bubble are the HUMAN's words (the verbatim comment),
|
||||
# never the host frame; provenance is its own field and the real
|
||||
# transport's client_surface is never substituted.
|
||||
assert queued["text"] == "prod parity"
|
||||
assert "[Owner quiz answer]" not in queued["text"]
|
||||
assert queued["task_metadata"]["late_answer"] == {"task_id": "task-1", "quiz_id": "q1"}
|
||||
assert "client_surface" not in queued["task_metadata"]
|
||||
# The same user bubble the owner's own typing produces.
|
||||
echo = [f for f in frames if f.get("type") == "chat" and f.get("role") == "user"]
|
||||
assert len(echo) == 1 and echo[0]["client_message_id"] == source_id
|
||||
assert echo[0]["chat_id"] == 1 and echo[0]["content"] == queued["text"]
|
||||
assert len(_accepted_rows(tmp_path, source_id)) == 1
|
||||
[row] = _accepted_rows(tmp_path, source_id)
|
||||
assert row["text"] == echo[0]["content"] == "prod parity"
|
||||
|
||||
# A retry of the SAME request re-enters delivery; the named ingress rejoins.
|
||||
again = _post(app, {"request_id": "r1", "decision_id": "quiz:task-1:q1",
|
||||
|
|
@ -219,3 +221,222 @@ def test_a_second_answer_to_a_settled_card_is_still_a_first_wins_409(tmp_path, m
|
|||
assert loser.json()["state"] == STATE_ANSWERED
|
||||
assert loser.json()["answered_index"] == 1
|
||||
assert _inbox(bridge) == []
|
||||
|
||||
|
||||
def test_a_button_only_late_answer_speaks_the_pressed_option_as_the_owner(tmp_path, monkeypatch):
|
||||
"""No comment: the owner's row, queued text and bubble are the pressed
|
||||
option exactly as the button showed it (the ingress refuses empty text),
|
||||
and still never the English host frame."""
|
||||
record_asked(tmp_path, "task-1", quiz_id="q1", question="Which db?",
|
||||
options=["sqlite", "postgres"], assumption="sqlite meanwhile", chat_id=1)
|
||||
reconcile_terminal(tmp_path, "task-1")
|
||||
bridge, frames = _late_bridge(tmp_path, monkeypatch)
|
||||
app = _decision_app(tmp_path, monkeypatch, live_task=None)
|
||||
resp = _post(app, {"request_id": "r1", "decision_id": "quiz:task-1:q1", "option_index": 1})
|
||||
assert resp.status_code == 200 and resp.json()["forwarded"] is True
|
||||
[queued] = _inbox(bridge)
|
||||
assert queued["text"] == "2. postgres"
|
||||
[echo] = [f for f in frames if f.get("type") == "chat" and f.get("role") == "user"]
|
||||
[row] = _accepted_rows(tmp_path, "quiz_late_answer:task-1:q1")
|
||||
assert echo["content"] == row["text"] == "2. postgres"
|
||||
assert "[Owner quiz answer]" not in row["text"]
|
||||
|
||||
|
||||
def test_a_retry_of_a_late_answer_accepted_with_the_old_frame_text_rejoins(tmp_path, monkeypatch):
|
||||
"""A late answer accepted before the row carried only the owner's words has
|
||||
the host frame under the same id; a retry now rejoins that delivery instead
|
||||
of failing forever on the text mismatch or enqueueing a second owner turn."""
|
||||
import supervisor.message_bus as mb
|
||||
|
||||
record_asked(tmp_path, "task-1", quiz_id="q1", question="Which db?",
|
||||
options=["sqlite", "postgres"], assumption="sqlite meanwhile", chat_id=1)
|
||||
reconcile_terminal(tmp_path, "task-1")
|
||||
bridge, frames = _late_bridge(tmp_path, monkeypatch)
|
||||
mb.log_chat("in", 1, 1, "[Owner quiz answer] quiz q1 -- old frame", source="web",
|
||||
client_message_id="quiz_late_answer:task-1:q1", drive_root=tmp_path,
|
||||
require_write=True)
|
||||
app = _decision_app(tmp_path, monkeypatch, live_task=None)
|
||||
resp = _post(app, {"request_id": "r1", "decision_id": "quiz:task-1:q1", "option_index": 1})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["forwarded"] is True
|
||||
assert _inbox(bridge) == [] and not [f for f in frames if f.get("type") == "chat"]
|
||||
assert len(_accepted_rows(tmp_path, "quiz_late_answer:task-1:q1")) == 1
|
||||
|
||||
|
||||
def _answered_late(tmp_path, *, option_index, comment=""):
|
||||
from ouroboros.owner_quiz import record_answered
|
||||
|
||||
record_asked(tmp_path, "task-1", quiz_id="q1", question="Which db for the pilot?",
|
||||
options=["sqlite", "postgres"], assumption="sqlite meanwhile", chat_id=1)
|
||||
reconcile_terminal(tmp_path, "task-1")
|
||||
outcome = record_answered(tmp_path, "task-1", quiz_id="q1", option_index=option_index,
|
||||
request_id="r1", comment=comment, allow_expired=True)
|
||||
assert outcome["ok"] is True
|
||||
return outcome["block"]
|
||||
|
||||
|
||||
def test_the_drained_late_answer_gives_the_model_the_rebuilt_frame_while_the_row_stays_human(tmp_path):
|
||||
"""Project-room mailbox delivery: the entry (and the owner's row) carry only
|
||||
the owner's words; the drain rebuilds the FULL card frame from the stored
|
||||
block for the model and records the owner directive with that frame."""
|
||||
import queue as queue_mod
|
||||
from types import SimpleNamespace
|
||||
|
||||
from ouroboros.loop_round_limits import _drain_incoming_messages
|
||||
from ouroboros.owner_mailbox import drain_owner_entries, write_owner_message
|
||||
|
||||
block = _answered_late(tmp_path, option_index=None, comment="neither -- use duckdb")
|
||||
assert write_owner_message(
|
||||
tmp_path, "neither -- use duckdb", "live-root", msg_id="quiz_late_answer:task-1:q1:live-root",
|
||||
client_message_id="quiz_late_answer:task-1:q1",
|
||||
late_answer={"task_id": "task-1", "quiz_id": "q1"},
|
||||
)
|
||||
[entry] = drain_owner_entries(tmp_path, "live-root", set(), include_acknowledged=True)
|
||||
assert entry["text"] == "neither -- use duckdb"
|
||||
assert entry["late_answer"] == {"task_id": "task-1", "quiz_id": "q1"}
|
||||
|
||||
ctx = SimpleNamespace()
|
||||
messages = [{"role": "user", "content": "Initial requirement"}]
|
||||
_drain_incoming_messages(messages, queue_mod.Queue(), tmp_path, "live-root", None, set(),
|
||||
owner_ctx=ctx)
|
||||
delivered = str(messages[-1]["content"])
|
||||
assert "[Owner quiz answer] quiz q1" in delivered
|
||||
assert f"asked {block['asked_at']}" in delivered and f"answered {block['answered_at']}" in delivered
|
||||
assert "Question was: Which db for the pilot?" in delivered
|
||||
assert ("The owner answered in their own words without choosing an offered option. "
|
||||
"Verbatim: neither -- use duckdb") in delivered
|
||||
[directive] = [row for row in ctx._owner_directives if row["source"] == "owner_mailbox"]
|
||||
assert "[Owner quiz answer] quiz q1" in directive["content"]
|
||||
assert "Verbatim: neither -- use duckdb" in directive["content"]
|
||||
# The steer relay's delivery fact stays the owner's own message.
|
||||
assert ctx.last_owner_delivery["text"] == "neither -- use duckdb"
|
||||
|
||||
|
||||
def test_an_ordinary_mailbox_message_is_not_reframed(tmp_path):
|
||||
"""The still-working case: a mailbox message without late_answer provenance
|
||||
reaches the model as the owner's words, unchanged."""
|
||||
import queue as queue_mod
|
||||
from types import SimpleNamespace
|
||||
|
||||
from ouroboros.loop_round_limits import _drain_incoming_messages
|
||||
from ouroboros.owner_mailbox import write_owner_message
|
||||
|
||||
_answered_late(tmp_path, option_index=1)
|
||||
assert write_owner_message(tmp_path, "please also fix the test", "live-root", msg_id="m1")
|
||||
ctx = SimpleNamespace()
|
||||
messages = [{"role": "user", "content": "Initial requirement"}]
|
||||
_drain_incoming_messages(messages, queue_mod.Queue(), tmp_path, "live-root", None, set(),
|
||||
owner_ctx=ctx)
|
||||
delivered = str(messages[-1]["content"])
|
||||
assert "please also fix the test" in delivered and "[Owner quiz answer]" not in delivered
|
||||
|
||||
|
||||
def test_an_unreadable_card_is_disclosed_with_the_owners_words(tmp_path):
|
||||
"""The block is gone (evicted, or the task result unreadable): the model
|
||||
gets the owner's words plus one host line naming the quiz, never silence."""
|
||||
import queue as queue_mod
|
||||
from types import SimpleNamespace
|
||||
|
||||
from ouroboros.loop_round_limits import _drain_incoming_messages
|
||||
from ouroboros.owner_mailbox import write_owner_message
|
||||
|
||||
assert write_owner_message(
|
||||
tmp_path, "2. postgres", "live-root", msg_id="late-1",
|
||||
late_answer={"task_id": "task-gone", "quiz_id": "q9"},
|
||||
)
|
||||
ctx = SimpleNamespace()
|
||||
messages = [{"role": "user", "content": "Initial requirement"}]
|
||||
_drain_incoming_messages(messages, queue_mod.Queue(), tmp_path, "live-root", None, set(),
|
||||
owner_ctx=ctx)
|
||||
delivered = str(messages[-1]["content"])
|
||||
assert "2. postgres" in delivered
|
||||
assert "answers quiz q9 of task task-gone; that card could not be read" in delivered
|
||||
assert "[Owner quiz answer]" not in delivered
|
||||
|
||||
|
||||
def test_a_late_answer_direct_turn_starts_with_the_rebuilt_frame(tmp_path, monkeypatch):
|
||||
"""Main / no live root: the late answer starts an ordinary owner turn whose
|
||||
task carries the late_answer provenance; the model's first user content (and
|
||||
therefore the run's initial owner directive) is the rebuilt frame, while the
|
||||
owner's row stays their words. A turn without that provenance is unchanged."""
|
||||
import queue as queue_mod
|
||||
|
||||
import supervisor.workers as workers
|
||||
from ouroboros.context import build_user_content
|
||||
|
||||
block = _answered_late(tmp_path, option_index=1, comment="prod parity")
|
||||
monkeypatch.setattr(workers, "DRIVE_ROOT", tmp_path)
|
||||
monkeypatch.setattr(workers, "get_event_q", lambda: queue_mod.Queue())
|
||||
|
||||
class _Agent:
|
||||
task = None
|
||||
|
||||
def handle_task(self, task):
|
||||
self.task = task
|
||||
return []
|
||||
|
||||
agent = _Agent()
|
||||
workers._run_chat_task(agent, 1, "prod parity", None, task_metadata={
|
||||
"client_message_id": "quiz_late_answer:task-1:q1",
|
||||
"late_answer": {"task_id": "task-1", "quiz_id": "q1"},
|
||||
})
|
||||
content = str(build_user_content(agent.task))
|
||||
assert "[Owner quiz answer] quiz q1" in content
|
||||
assert f"answered {block['answered_at']}" in content
|
||||
assert "The owner chose option 2: postgres" in content
|
||||
assert "Owner comment (verbatim): prod parity" in content
|
||||
|
||||
plain = _Agent()
|
||||
workers._run_chat_task(plain, 1, "prod parity", None, task_metadata={"client_message_id": "m-2"})
|
||||
assert plain.task["text"] == "prod parity"
|
||||
|
||||
|
||||
def test_a_late_answer_routed_into_a_project_rooms_live_root_carries_its_provenance(tmp_path, monkeypatch):
|
||||
"""End to end through the real bridge intake: a late answer in a Project room
|
||||
with exactly one live root lands in THAT root's mailbox as the owner's words
|
||||
with its typed late_answer provenance, so the drain can rebuild the frame."""
|
||||
import queue as queue_mod
|
||||
from types import SimpleNamespace
|
||||
|
||||
import server
|
||||
import supervisor.message_bus as mb
|
||||
from ouroboros.loop_round_limits import _drain_incoming_messages
|
||||
from ouroboros.owner_mailbox import drain_owner_entries
|
||||
from ouroboros.projects_registry import create_project
|
||||
|
||||
project = create_project(tmp_path, "racer")
|
||||
chat_id = int(project["chat_id"])
|
||||
record_asked(tmp_path, "task-1", quiz_id="q1", question="Which db?",
|
||||
options=["sqlite", "postgres"], assumption="sqlite meanwhile", chat_id=chat_id)
|
||||
reconcile_terminal(tmp_path, "task-1")
|
||||
bridge, _frames = _late_bridge(tmp_path, monkeypatch)
|
||||
monkeypatch.setattr(mb, "load_state", lambda: {"session_id": "s-1"})
|
||||
app = _decision_app(tmp_path, monkeypatch, live_task=None)
|
||||
resp = _post(app, {"request_id": "r1", "decision_id": "quiz:task-1:q1",
|
||||
"option_index": 1, "comment": "prod parity"})
|
||||
assert resp.status_code == 200 and resp.json()["forwarded"] is True
|
||||
|
||||
pending = [{"id": "pending-root", "chat_id": chat_id, "root_task_id": "pending-root",
|
||||
"delegation_role": "root", "drive_root": str(tmp_path)}]
|
||||
ctx = SimpleNamespace(
|
||||
DRIVE_ROOT=tmp_path, PENDING=pending, RUNNING={},
|
||||
load_state=lambda: {"owner_id": 1, "owner_chat_id": 1, "session_id": "s-1"},
|
||||
update_state=lambda fn: fn({"owner_id": 1, "owner_chat_id": 1}),
|
||||
consciousness=SimpleNamespace(inject_observation=lambda _text: None),
|
||||
get_chat_agent=lambda: SimpleNamespace(_busy=False),
|
||||
handle_chat_direct=lambda *a, **k: pytest.fail("mailbox delivery must not run a turn"),
|
||||
send_with_budget=lambda *a, **k: None,
|
||||
)
|
||||
monkeypatch.setattr(bridge, "send_routing_ack", lambda *a, **k: None, raising=False)
|
||||
server._process_bridge_updates(bridge, 0, ctx)
|
||||
|
||||
[entry] = drain_owner_entries(tmp_path, "pending-root", set(), include_acknowledged=True)
|
||||
assert entry["text"] == "prod parity"
|
||||
assert entry["late_answer"] == {"task_id": "task-1", "quiz_id": "q1"}
|
||||
owner_ctx = SimpleNamespace()
|
||||
messages = [{"role": "user", "content": "Initial requirement"}]
|
||||
_drain_incoming_messages(messages, queue_mod.Queue(), tmp_path, "pending-root", None, set(),
|
||||
owner_ctx=owner_ctx)
|
||||
delivered = str(messages[-1]["content"])
|
||||
assert "The owner chose option 2: postgres" in delivered
|
||||
assert "Owner comment (verbatim): prod parity" in delivered
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue