""" Unit tests for :mod:`omnigent.runtime.pending_inputs`. The pending-inputs index holds web-composer user messages on native-terminal sessions that haven't yet round-tripped back through the transcript forwarder. It backs the optimistic "queued message" bubble across a client re-bind by replaying un-consumed messages into the session snapshot. Tests here pin its core invariants directly: * :func:`record` assigns a unique id and :func:`snapshot_for` replays entries in FIFO (insertion) order with their content verbatim. * :func:`resolve_oldest` drains the oldest entry (FIFO) and returns its id, regardless of the persisted message's text — the transcript reformats text (reply quotes, attachment markers), so order is the only reliable correlation signal. Returns ``None`` when empty. * :func:`resolve` removes an entry by id (the forward-failed rollback path) and is idempotent. * Stale entries are evicted after :data:`pending_inputs._TTL_S` — the ghost-cleanup backstop for a message the TUI never accepted. The wire-up between the route layer and the index (record on POST, drain at persist, replay in the snapshot) is covered by the server route tests; this file tests the module in isolation. """ from __future__ import annotations from collections.abc import Iterator import pytest from omnigent.runtime import pending_inputs @pytest.fixture(autouse=True) def _clean_pending_inputs_index() -> Iterator[None]: """ Reset the module-global pending-inputs dict between tests. The index is process-global; without this fixture a leaked entry would change the snapshot/match behavior of every later test. """ pending_inputs.reset_for_tests() yield pending_inputs.reset_for_tests() def _text_block(text: str) -> dict[str, object]: """ Build a minimal ``input_text`` content block. :param text: The message text, e.g. ``"hello"``. :returns: A content block dict, e.g. ``{"type": "input_text", "text": "hello"}``. """ return {"type": "input_text", "text": text} def test_record_then_snapshot_preserves_order_and_content() -> None: """ Snapshot replays recorded messages FIFO with content verbatim. Proves a (re)connecting client re-hydrates exactly what it posted, in submission order. A failure here means the snapshot lost an entry, reordered them, or mangled the content blocks — the bubble would render wrong or vanish on re-bind. """ first = pending_inputs.record("conv_a", [_text_block("first")]) second = pending_inputs.record("conv_a", [_text_block("second")]) snap = pending_inputs.snapshot_for("conv_a") # Two distinct ids in insertion order — not deduped, not reordered. assert [e["pending_id"] for e in snap] == [first, second] assert first != second # Content round-trips verbatim (real file ids / text survive replay). assert snap[0]["content"] == [_text_block("first")] assert snap[1]["content"] == [_text_block("second")] def test_snapshot_returns_deep_copies() -> None: """ Mutating a snapshot entry must not corrupt the stored content. The snapshot is serialized onto the wire; a shallow copy would let a caller's mutation leak back into the index and poison a later replay. Asserts the stored content is unchanged after mutation. """ pending_inputs.record("conv_a", [_text_block("orig")]) snap = pending_inputs.snapshot_for("conv_a") snap[0]["content"][0]["text"] = "mutated" # Re-read: the index still holds the original text, not "mutated". assert pending_inputs.snapshot_for("conv_a")[0]["content"] == [_text_block("orig")] def test_resolve_oldest_drains_fifo_and_returns_entry() -> None: """ A persisted message drains the oldest pending entry (FIFO). This is the dedupe that stops the now-committed item from double-rendering next to its stale optimistic bubble. Per-session SSE ordering means the i-th persisted user message is the i-th queued one, so draining is oldest-first. Asserts the first recorded entry drains first (with its id + content) and the second remains. """ first = pending_inputs.record("conv_a", [_text_block("first")]) second = pending_inputs.record("conv_a", [_text_block("second")]) drained = pending_inputs.resolve_oldest("conv_a") # Oldest entry drains first; its id is echoed back so the client can # drop that bubble by id, and its content lets the caller fold file # blocks into the durable item. assert drained is not None assert drained.pending_id == first assert drained.content == [_text_block("first")] assert [e["pending_id"] for e in pending_inputs.snapshot_for("conv_a")] == [second] # Then the next-oldest. assert pending_inputs.resolve_oldest("conv_a").pending_id == second # type: ignore[union-attr] assert pending_inputs.snapshot_for("conv_a") == [] def test_resolve_oldest_returns_none_when_empty() -> None: """ Draining with nothing pending returns ``None``. A message typed directly in the TUI on a session with no queued web messages has no pending entry; the caller then renders it as a plain committed item (``cleared_pending_id`` is ``None``). """ assert pending_inputs.resolve_oldest("conv_a") is None def test_resolve_oldest_drains_regardless_of_reformatted_text() -> None: """ Regression: a queued message drains even when the transcript reformats its text (reply-quote / attachment markers / whitespace). The bug: matching the pending entry to the persisted item *by text* broke when the native transcript reformatted the message — e.g. a reply-quote POSTed as ``"> quoted\\n\\nmy question"`` round-tripped back as differently-formatted text. The text match then failed, the entry never drained, and the message double-rendered (committed bubble + stranded pending bubble) and survived reload until the TTL. FIFO draining is immune: it ignores the content entirely. Here the stored content (with blockquote markers) is drained by order even though the persisted text it corresponds to looks nothing like it. """ quoted = [_text_block("> modeling a crash where set_offline never ran)\n\nIs this the only?")] pid = pending_inputs.record("conv_a", quoted) # The persisted/round-tripped text is irrelevant to draining — order # is the only signal. The entry drains and is gone (no ghost). drained = pending_inputs.resolve_oldest("conv_a") assert drained is not None and drained.pending_id == pid assert pending_inputs.snapshot_for("conv_a") == [] def test_resolve_oldest_returns_content_with_file_blocks() -> None: """ The drained entry carries its file blocks for durable merge. Native transcript items are text-only, so the persist site folds the drained entry's image/file blocks into the durable item to keep the image in history. That only works if :func:`resolve_oldest` hands back the original content (with real ``file_id``s), not just the id. """ content = [ {"type": "input_image", "file_id": "file_real", "filename": "a.png"}, _text_block("look"), ] pending_inputs.record("conv_a", content) drained = pending_inputs.resolve_oldest("conv_a") assert drained is not None # The image block survives the drain so the caller can re-attach it. assert drained.content == content def test_resolve_matching_text_skips_older_unmatched_entries() -> None: """Kiro can match the accepted prompt and identify older failed inputs.""" first = pending_inputs.record( "conv_a", [_text_block("!!!! XOXOX !!!!")], created_by="alice@example.com" ) second = pending_inputs.record("conv_a", [_text_block("tell me a joke")]) drained = pending_inputs.resolve_matching_text("conv_a", "tell me a joke") assert drained.matched is not None assert drained.matched.pending_id == second assert drained.matched.content == [_text_block("tell me a joke")] assert [entry.pending_id for entry in drained.skipped] == [first] assert drained.skipped[0].content == [_text_block("!!!! XOXOX !!!!")] assert drained.skipped[0].created_by == "alice@example.com" assert pending_inputs.snapshot_for("conv_a") == [] def test_resolve_matching_text_leaves_entries_when_no_text_matches() -> None: """A direct Kiro TUI prompt must not consume unrelated web pending entries.""" first = pending_inputs.record("conv_a", [_text_block("web input")]) drained = pending_inputs.resolve_matching_text("conv_a", "typed in terminal") assert drained.matched is None assert drained.skipped == [] assert [entry["pending_id"] for entry in pending_inputs.snapshot_for("conv_a")] == [first] def test_resolve_removes_entry_idempotently() -> None: """ :func:`resolve` drops an entry by id (forward-failed rollback). When the runner forward fails the route rolls back the record so a never-delivered message leaves no ghost bubble. Asserts the entry is removed and a second resolve of the same id is a harmless no-op. """ keep = pending_inputs.record("conv_a", [_text_block("keep")]) drop = pending_inputs.record("conv_a", [_text_block("drop")]) pending_inputs.resolve("conv_a", drop) assert [e["pending_id"] for e in pending_inputs.snapshot_for("conv_a")] == [keep] # Idempotent — resolving an already-removed id does nothing. pending_inputs.resolve("conv_a", drop) assert [e["pending_id"] for e in pending_inputs.snapshot_for("conv_a")] == [keep] def test_entries_are_scoped_per_conversation() -> None: """ One conversation's pending messages never leak into another's. A multi-user server holds many sessions in the same process; a snapshot for conv B must never replay conv A's queued bubble. """ a = pending_inputs.record("conv_a", [_text_block("for a")]) pending_inputs.record("conv_b", [_text_block("for b")]) assert [e["pending_id"] for e in pending_inputs.snapshot_for("conv_a")] == [a] # conv_b's snapshot doesn't contain conv_a's entry. assert all(e["pending_id"] != a for e in pending_inputs.snapshot_for("conv_b")) def test_created_by_round_trips_through_drain() -> None: """ :func:`resolve_oldest` returns the ``created_by`` stored at record time. The persist site applies the drained author to the ``NewConversationItem`` so ``session.input.consumed`` broadcasts the correct identity to all clients. A failure here means collaborators (who never saw the optimistic bubble) receive ``created_by=None`` and the author label never appears for them on the committed message. """ pending_inputs.record( "conv_a", [_text_block("alice's message")], created_by="alice@example.com" ) drained = pending_inputs.resolve_oldest("conv_a") assert drained is not None assert drained.created_by == "alice@example.com" def test_created_by_none_when_not_provided() -> None: """ Entries recorded without ``created_by`` drain with ``None``. Covers callers that don't provide an author (e.g. pre-attribution code or unknown actor). The persist site guards on ``drained.created_by is not None`` before applying it, so ``None`` is a safe no-op. """ pending_inputs.record("conv_a", [_text_block("anonymous")]) drained = pending_inputs.resolve_oldest("conv_a") assert drained is not None assert drained.created_by is None def test_created_by_in_snapshot() -> None: """ :func:`snapshot_for` includes ``created_by`` when present. A collaborator who reconnects while a message is still in-flight re-hydrates the optimistic bubble from the snapshot. Without ``created_by`` in the snapshot payload the frontend cannot stamp the correct author on the bubble; the collaborator would either see their own email (wrong) or no label at all. """ pending_inputs.record("conv_a", [_text_block("hi")], created_by="alice@example.com") snap = pending_inputs.snapshot_for("conv_a") assert len(snap) == 1 assert snap[0]["created_by"] == "alice@example.com" def test_created_by_absent_from_snapshot_when_none() -> None: """ ``created_by`` is omitted from the snapshot dict when not set. Keeps the wire payload backward-compatible: clients that pre-date this field see no unknown key rather than an explicit ``null``. """ pending_inputs.record("conv_a", [_text_block("hi")]) snap = pending_inputs.snapshot_for("conv_a") assert "created_by" not in snap[0] def test_stale_entries_evicted_after_ttl(monkeypatch: pytest.MonkeyPatch) -> None: """ A never-drained entry is evicted once it ages past the TTL. This is the ghost-cleanup backstop for a message the vendor TUI never accepted (runner crash, dropped keystrokes): with no matching persist to drain it, it must not replay forever. Drive the clock via the ``_now`` seam so no real sleep is needed. Asserts the entry is present just under the TTL and gone just over it. A failure means eviction never fires (permanent ghost bubble) or fires too eagerly (a slow-but-valid round-trip loses its bubble). """ clock = {"t": 1000.0} # Patch the module's own _now seam (not time.monotonic globally) so # only this index sees the advanced clock — see testing rule 14. monkeypatch.setattr(pending_inputs, "_now", lambda: clock["t"]) pid = pending_inputs.record("conv_a", [_text_block("ghost")]) # Just under the TTL: a slow transcript round-trip still finds it. clock["t"] = 1000.0 + pending_inputs._TTL_S - 0.1 assert [e["pending_id"] for e in pending_inputs.snapshot_for("conv_a")] == [pid] # Past the TTL: the lazy sweep on the next access evicts the ghost. clock["t"] = 1000.0 + pending_inputs._TTL_S + 0.1 assert pending_inputs.snapshot_for("conv_a") == []