Files
turnstone/tests/test_history_projection.py
T
Patrick Buckley 21af6c4970 fix(ui): align system-turn row event_id with its SSE event (no double-render)
A first-class operator-context system turn (metacognition nudge, output-guard
finding, interjection, watch result) was persisted stamped with the event-id
counter's PRE-emit value, then its live `on_system_turn` SSE event was emitted
with the post-increment id — so the row sat one below its own event.  On an
in-flight-orphan `/history` resume, `_resume_cursor_and_trim` derives the SSE
replay cursor from the row's id; being one low, the replay redelivered the
turn's own `system_turn` event and the frontend (no dedup) painted the
operator bubble twice.  Reliable for coordinator-spawned children (opened
mid-task) and self-healing on rehydrate — a non-persisted, live-only double.

- `SessionUIBase._enqueue` returns the monotonic `_event_id` it assigns;
  `on_system_turn` returns it; `_append_system_turn` emits the hook first and
  persists the row with that id (fallback to the current cursor for non-SSE
  UIs / a throwing hook).  Now row.event_id == its own SSE event id.
- `project_history_messages` surfaces each row's `event_id` so the frontend
  can dedup.
- app.js: tag each SSE event with its id, reset a per-pane rendered-id set on
  `replayHistory`, and skip a `system_turn` already painted from `/history`
  (belt-and-braces against any future cursor skew).
- Regression tests pin the row/event id alignment, the `/history` emit, and
  the FE dedup.
2026-06-04 11:03:13 -07:00

312 lines
14 KiB
Python

"""Tests for the REST ``/history`` projection helpers.
``project_history_messages`` does the structural projection — collapse
multipart user content, surface the ``_source`` / ``_reminders``
side-channels, flatten tool_calls, derive ``denied`` / ``is_error`` /
``pending`` — that the interactive ``replayHistory`` renderer and the
coordinator dashboard both consume directly. ``extract_reasoning_for_history``
surfaces stored reasoning text and strips the internal ``_provider_content``
lane. Together they compose the ``make_history_handler`` pipeline.
Persisted via migration 050 (source / reminders) and migration 052
(reasoning) so multi-tab / multi-device replay sees the same metacognitive
bubble shape the originating tab saw live.
"""
from __future__ import annotations
from typing import Any
from turnstone.core.history_decoration import (
extract_reasoning_for_history,
project_history_messages,
)
class TestSourceSurfacing:
def test_source_surfaces_when_set(self) -> None:
history = project_history_messages(
[{"role": "user", "content": "", "_source": "system_nudge"}]
)
assert len(history) == 1
assert history[0]["source"] == "system_nudge"
def test_source_absent_when_unset(self) -> None:
history = project_history_messages([{"role": "user", "content": "hello"}])
assert "source" not in history[0]
class TestSystemTurnProjection:
"""First-class operator-context ``system`` rows project ``_source`` →
``source`` so the frontend can label/style the operator bubble. The
legacy ``_reminders`` side-channel projection is gone (operator context
no longer rides that column)."""
def test_system_turn_source_projects(self) -> None:
history = project_history_messages(
[
{
"role": "system",
"_source": "user_interjection",
"content": "check the logs",
}
]
)
assert history[0]["role"] == "system"
assert history[0]["source"] == "user_interjection"
assert history[0]["content"] == "check the logs"
def test_system_turn_source_meta_projects(self) -> None:
# ``_source_meta`` → ``meta`` so a reconnecting tab rebuilds the same
# per-kind card (the watch-result card etc.) the live SSE event drives.
history = project_history_messages(
[
{
"role": "system",
"_source": "watch_triggered",
"content": "ci failed",
"_source_meta": {"watch_name": "ci", "poll_count": 3},
}
]
)
assert history[0]["source"] == "watch_triggered"
assert history[0]["meta"] == {"watch_name": "ci", "poll_count": 3}
def test_system_turn_without_meta_omits_meta_field(self) -> None:
history = project_history_messages(
[{"role": "system", "_source": "correction", "content": "watch out"}]
)
assert "meta" not in history[0]
def test_event_id_surfaces_when_set(self) -> None:
"""``_event_id`` → top-level ``event_id`` so the frontend can dedup a
``/history``-painted system turn against an SSE replay that redelivers
it (the resume-cursor seam)."""
history = project_history_messages(
[{"role": "system", "_source": "start", "content": "x", "_event_id": 7}]
)
assert history[0]["event_id"] == 7
def test_event_id_absent_when_unset(self) -> None:
history = project_history_messages([{"role": "user", "content": "hello"}])
assert "event_id" not in history[0]
def test_legacy_reminders_column_not_projected(self) -> None:
"""A pre-migration row that still carries ``_reminders`` must NOT
surface a ``reminders`` field — the projection dropped that lane."""
history = project_history_messages(
[
{
"role": "user",
"content": "noted",
"_reminders": [{"type": "correction", "text": "watch out"}],
}
]
)
assert "reminders" not in history[0]
class TestReasoningSurfacing:
"""``extract_reasoning_for_history`` surfaces stored Anthropic thinking
blocks on the assistant message (so refresh-the-page rehydrates the
reasoning bubble) and strips the internal ``_provider_content`` lane.
Drives through the real ``AnthropicProvider`` extractor — only the
surface flag is a parameter (the active-model flag resolution lives in
``make_history_handler``, covered by its REST tests).
"""
def test_reasoning_surfaces_for_anthropic_thinking_msg(self) -> None:
msgs: list[dict[str, Any]] = [
{
"role": "assistant",
"content": "Final answer.",
"_provider_content": [
{"type": "thinking", "thinking": "let me think", "signature": "s"},
{"type": "text", "text": "Final answer."},
],
}
]
extract_reasoning_for_history(msgs, surface_persisted_reasoning_flag=True)
assert msgs[0]["reasoning"] == "let me think"
def test_reasoning_empty_when_persist_flag_false(self) -> None:
msgs: list[dict[str, Any]] = [
{
"role": "assistant",
"content": "Final answer.",
"_provider_content": [
{"type": "thinking", "thinking": "hidden", "signature": "s"},
],
}
]
extract_reasoning_for_history(msgs, surface_persisted_reasoning_flag=False)
assert "reasoning" not in msgs[0]
def test_provider_content_always_stripped(self) -> None:
# The internal lane is stripped regardless of the flag — the wire
# payload never carries it.
for flag in (True, False):
msgs: list[dict[str, Any]] = [
{
"role": "assistant",
"content": "Final answer.",
"_provider_content": [
{"type": "thinking", "thinking": "x", "signature": "s"},
],
}
]
extract_reasoning_for_history(msgs, surface_persisted_reasoning_flag=flag)
assert "_provider_content" not in msgs[0]
def test_no_reasoning_field_when_provider_content_missing(self) -> None:
msgs: list[dict[str, Any]] = [{"role": "assistant", "content": "plain answer"}]
extract_reasoning_for_history(msgs, surface_persisted_reasoning_flag=True)
assert "reasoning" not in msgs[0]
def test_no_reasoning_field_for_non_assistant_messages(self) -> None:
# Defensive — user/tool messages are skipped entirely; a stray
# _provider_content on them never gets a reasoning field stamped.
msgs: list[dict[str, Any]] = [
{"role": "user", "content": "hi"},
{
"role": "tool",
"tool_call_id": "c1",
"content": "out",
"_provider_content": [{"type": "thinking", "thinking": "leak", "signature": "s"}],
},
]
extract_reasoning_for_history(msgs, surface_persisted_reasoning_flag=True)
assert "reasoning" not in msgs[0]
assert "reasoning" not in msgs[1]
class TestProjectHistoryMessages:
"""End-to-end shape test — the Python port of the retired client-side
``normalizeHistoryMessages`` node test. Feeds the provider-native
``reconstruct_messages`` storage shape (nested tool_calls,
``_source`` / ``_reminders`` / ``_attachments_meta`` side-channels,
multipart content, no derived flags) and asserts the canonical
projected wire shape both UIs consume.
"""
def test_projects_storage_shape_to_wire_shape(self) -> None:
raw: list[dict[str, Any]] = [
{
"role": "user",
"content": [
{"type": "text", "text": "hi"},
{"type": "image_url", "image_url": {}},
],
"_source": "system_nudge",
"_reminders": [
{"type": "correction", "text": "fix", "secret": "x"},
{"type": "", "text": ""},
],
"_attachments_meta": [
{"kind": "image", "filename": "p.png", "mime_type": "image/png"}
],
},
{
"role": "assistant",
"content": "ok",
"reasoning": "think", # already stamped by extract_reasoning_for_history
"tool_calls": [
{
"id": "c1",
"type": "function",
"function": {"name": "web_search", "arguments": '{"q":1}'},
"verdict": {"tier": "judge"},
}
],
},
{"role": "tool", "tool_call_id": "c1", "content": "res"},
{
"role": "assistant",
"content": "",
"tool_calls": [{"id": "c2", "function": {"name": "bash", "arguments": "{}"}}],
},
{"role": "tool", "tool_call_id": "c2", "content": "Denied by user: no"},
{"role": "tool", "tool_call_id": "cx", "content": "Error: boom"},
# mid-conversation orphan: tool_call with no result that is NOT
# the last tool turn → must still render (not vanish), NOT pending.
{
"role": "assistant",
"tool_calls": [{"id": "c_mid", "function": {"name": "g", "arguments": "{}"}}],
},
# trailing orphan: last tool turn with no result → pending (awaiting).
{
"role": "assistant",
"tool_calls": [{"id": "c3", "function": {"name": "f", "arguments": "{}"}}],
},
]
out = project_history_messages(raw)
# tool_calls flattened: name / arguments / verdict top-level
assert out[1]["tool_calls"][0]["name"] == "web_search"
assert out[1]["tool_calls"][0]["arguments"] == '{"q":1}'
assert out[1]["tool_calls"][0]["verdict"]["tier"] == "judge"
# multipart user content collapsed; side-channels surfaced top-level
assert out[0]["content"] == "hi"
assert out[0]["attachments"][0]["filename"] == "p.png" # _attachments_meta wins
assert out[0]["source"] == "system_nudge"
# The legacy ``_reminders`` lane is gone — operator context rides
# first-class ``system`` rows now, not a projected ``reminders`` field.
assert "reminders" not in out[0]
# reasoning passes through (already stamped upstream)
assert out[1]["reasoning"] == "think"
# derived + propagated flags (the storage shape pre-sets none)
assert out[4]["denied"] is True # tool deny derived from content prefix
assert out[3]["denied"] is True # propagated to the parent assistant turn
assert out[5]["is_error"] is True # tool error derived from content prefix
# ``pending`` is a LIVE-state decision gated on ``awaiting_approval``
# (default False here) — NOT orphan-detection. So even the trailing
# orphan renders its tool block by default; see
# ``test_pending_gated_on_awaiting_approval`` for the gate.
assert out[7].get("pending") is not True # trailing orphan c3 — not awaiting → renders
assert out[6].get("pending") is not True # mid-conversation orphan c_mid renders
assert out[1].get("pending") is not True # resolved c1
def test_pending_gated_on_awaiting_approval(self) -> None:
"""``pending`` marks the LAST orphan tool-call turn only when the
caller passes ``awaiting_approval=True`` (the live ``_pending_approval``
read). This is the regression guard for the fresh-connect bug: an
orphan tool call mid-execution is NOT awaiting approval, so it must
render its tool block (``pending`` absent) rather than vanish until a
reconnect replays the buffered events.
"""
raw: list[dict[str, Any]] = [
# resolved turn (has a tool result)
{
"role": "assistant",
"content": "",
"tool_calls": [{"id": "c1", "function": {"name": "f", "arguments": "{}"}}],
},
{"role": "tool", "tool_call_id": "c1", "content": "res"},
# mid-conversation orphan (NOT the last tool turn)
{
"role": "assistant",
"tool_calls": [{"id": "c_mid", "function": {"name": "g", "arguments": "{}"}}],
},
{"role": "user", "content": "carry on"},
# trailing orphan (last tool turn, no result)
{
"role": "assistant",
"tool_calls": [{"id": "c_last", "function": {"name": "h", "arguments": "{}"}}],
},
]
# Awaiting approval: ONLY the trailing orphan turn is pending.
awaiting = project_history_messages(raw, awaiting_approval=True)
assert awaiting[4].get("pending") is True # trailing orphan → skip static, live prompt
assert awaiting[2].get("pending") is not True # mid-conversation orphan still renders
assert awaiting[0].get("pending") is not True # resolved turn
# Executing / not awaiting: NOTHING is pending — the trailing orphan
# (a tool mid-execution) renders its tool block on a fresh connect.
executing = project_history_messages(raw, awaiting_approval=False)
executing_pending = [entry.get("pending") for entry in executing]
assert executing_pending == [None, None, None, None, None]
# Default matches awaiting_approval=False.
default_pending = [entry.get("pending") for entry in project_history_messages(raw)]
assert default_pending == [None, None, None, None, None]