Files
turnstone/tests/test_reconstruct_messages.py
T
Patrick Buckley 8513016503 refactor(storage): drop the dead _reminders column instead of carrying it
Operator context moved to first-class system turns, leaving _reminders written
by nothing and read by nothing. Nulling it (the prior 060 step) left a writable
dead column — a foot-gun inviting accidental reuse. Drop it outright and remove
every reference in one shot so there is no half-alive state:

- migration 060: replace the wholesale null with batch_alter_table drop_column
  (per migration 027); downgrade re-adds the empty column to match the 059
  schema (the envelope un-wrap stays irreversible).
- _schema.py: remove the column.
- _sqlite / _postgresql: drop the reminders save param, the INSERT/bulk values,
  and both SELECT columns.
- reconstruct_messages: the row tuple is now 8/9-tuple (event_id shifts from
  index 9 to 8); _utils + the _row test helper updated.
- _protocol / memory save_message: drop the reminders param + docstrings.
- tests: replace the reminders-roundtrip tests with a _source-only file and a
  060 drop-column assertion; remove the obsolete legacy-reminders wire test.

No production caller passed reminders=, and the SELECT no longer reads the
column, so an un-migrated DB simply ignores any residual values.
2026-06-04 11:03:13 -07:00

460 lines
17 KiB
Python

"""Tests for the shared message reconstruction logic."""
import itertools
import json
from turnstone.core.storage._utils import reconstruct_messages
_row_ids = itertools.count(1)
def _row(
role,
content=None,
tool_name=None,
tc_id=None,
pdata=None,
tool_calls=None,
source=None,
):
"""Build an 8-element conversation row tuple (id, role, ...).
Trailing ``source`` is the persisted twin of the in-memory ``_source``
side-channel. (The ``_reminders`` column that used to ride here was
dropped in migration 060 — operator context lives in ``system`` turns.)
"""
return (
next(_row_ids),
role,
content,
tool_name,
tc_id,
pdata,
tool_calls,
source,
)
class TestAssistantWithToolCalls:
"""Assistant messages with tool_calls JSON are self-contained."""
def test_assistant_with_tool_calls_and_content(self):
tc = json.dumps(
[
{
"id": "call_1",
"type": "function",
"function": {"name": "read_file", "arguments": '{"path":"/tmp/x"}'},
}
]
)
rows = [
_row("assistant", "Let me check that.", tool_calls=tc),
_row("tool", "file contents", tc_id="call_1"),
]
msgs = reconstruct_messages(rows, "ws1")
assert len(msgs) == 2
assert msgs[0]["role"] == "assistant"
assert msgs[0]["content"] == "Let me check that."
assert len(msgs[0]["tool_calls"]) == 1
assert msgs[0]["tool_calls"][0]["function"]["name"] == "read_file"
assert msgs[1]["role"] == "tool"
assert msgs[1]["tool_call_id"] == "call_1"
def test_assistant_with_multiple_tool_calls(self):
tc = json.dumps(
[
{
"id": "call_1",
"type": "function",
"function": {"name": "bash", "arguments": '{"command":"ls"}'},
},
{
"id": "call_2",
"type": "function",
"function": {"name": "bash", "arguments": '{"command":"pwd"}'},
},
]
)
rows = [
_row("assistant", tool_calls=tc),
_row("tool", "files", tc_id="call_1"),
_row("tool", "/home", tc_id="call_2"),
]
msgs = reconstruct_messages(rows, "ws1")
assert len(msgs) == 3
assert len(msgs[0]["tool_calls"]) == 2
assert msgs[1]["role"] == "tool"
assert msgs[2]["role"] == "tool"
def test_assistant_without_tool_calls(self):
rows = [_row("assistant", "Hello there.")]
msgs = reconstruct_messages(rows, "ws1")
assert len(msgs) == 1
assert msgs[0]["role"] == "assistant"
assert msgs[0]["content"] == "Hello there."
assert "tool_calls" not in msgs[0]
class TestMultipleTurns:
"""Multiple assistant turns with tool calls stay separate."""
def test_two_tool_call_turns(self):
tc1 = json.dumps(
[
{
"id": "call_1",
"type": "function",
"function": {"name": "bash", "arguments": '{"command":"ls"}'},
}
]
)
tc2 = json.dumps(
[
{
"id": "call_2",
"type": "function",
"function": {"name": "bash", "arguments": '{"command":"cat file1"}'},
}
]
)
rows = [
_row("assistant", "I'll run two commands.", tool_calls=tc1),
_row("tool", "file1\nfile2", tc_id="call_1"),
_row("assistant", "Now reading.", tool_calls=tc2),
_row("tool", "contents", tc_id="call_2"),
]
msgs = reconstruct_messages(rows, "ws1")
assert len(msgs) == 4
assert msgs[0]["content"] == "I'll run two commands."
assert len(msgs[0]["tool_calls"]) == 1
assert msgs[2]["content"] == "Now reading."
assert len(msgs[2]["tool_calls"]) == 1
def test_denied_tool_calls_with_commentary(self):
"""Two denied tool batches with assistant commentary in between."""
tc1 = json.dumps(
[
{
"id": "call_1",
"type": "function",
"function": {"name": "bash", "arguments": '{"command":"find /"}'},
}
]
)
tc2 = json.dumps(
[
{
"id": "call_2",
"type": "function",
"function": {"name": "bash", "arguments": '{"command":"curl ..."}'},
}
]
)
rows = [
_row("assistant", tool_calls=tc1),
_row("tool", "Denied by user", tc_id="call_1"),
_row("assistant", "Interesting! Let me try something else."),
_row("assistant", tool_calls=tc2),
_row("tool", "Denied by user", tc_id="call_2"),
]
msgs = reconstruct_messages(rows, "ws1")
assert len(msgs) == 5
assert msgs[0]["role"] == "assistant"
assert msgs[0]["tool_calls"][0]["function"]["name"] == "bash"
assert msgs[1]["role"] == "tool"
assert msgs[2]["role"] == "assistant"
assert msgs[2]["content"] == "Interesting! Let me try something else."
assert "tool_calls" not in msgs[2]
assert msgs[3]["role"] == "assistant"
assert msgs[3]["tool_calls"][0]["function"]["name"] == "bash"
assert msgs[4]["role"] == "tool"
class TestEdgeCases:
"""Edge cases in message reconstruction."""
def test_incomplete_turn_repair(self):
"""Trailing tool_calls without enough tool_results are stripped."""
tc = json.dumps(
[
{
"id": "call_1",
"type": "function",
"function": {"name": "bash", "arguments": '{"command":"ls"}'},
},
{
"id": "call_2",
"type": "function",
"function": {"name": "bash", "arguments": '{"command":"cat x"}'},
},
]
)
rows = [
_row("user", "hello"),
_row("assistant", "Let me check.", tool_calls=tc),
# Only 1 tool result for 2 tool_calls
_row("tool", "file1", tc_id="call_1"),
]
msgs = reconstruct_messages(rows, "ws1")
assert len(msgs) == 1
assert msgs[0]["role"] == "user"
def test_empty_rows(self):
msgs = reconstruct_messages([], "ws1")
assert msgs == []
def test_provider_data_preserved(self):
pdata = json.dumps([{"type": "text", "text": "hello"}])
rows = [_row("assistant", "hello", pdata=pdata)]
msgs = reconstruct_messages(rows, "ws1")
assert msgs[0]["_provider_content"] == [{"type": "text", "text": "hello"}]
def test_user_message(self):
rows = [_row("user", "hello world")]
msgs = reconstruct_messages(rows, "ws1")
assert len(msgs) == 1
assert msgs[0] == {"role": "user", "content": "hello world"}
def test_none_content_becomes_empty_string(self):
rows = [_row("user", None)]
msgs = reconstruct_messages(rows, "ws1")
assert msgs[0]["content"] == ""
def test_tool_without_tc_id_uses_empty_string(self):
rows = [
_row(
"assistant",
tool_calls=json.dumps(
[{"id": "c1", "type": "function", "function": {"name": "x", "arguments": ""}}]
),
),
_row("tool", "output", tc_id=None),
]
msgs = reconstruct_messages(rows, "ws1")
assert msgs[1]["tool_call_id"] == ""
def test_unknown_role_ignored(self):
"""Genuinely unknown roles are dropped; ``system``/``developer`` are
first-class and reconstructed (see TestSystemTurns)."""
rows = [
_row("user", "hi"),
_row("weird", "???"),
_row("assistant", "hello"),
]
msgs = reconstruct_messages(rows, "ws1")
assert len(msgs) == 2
assert msgs[0]["role"] == "user"
assert msgs[1]["role"] == "assistant"
class TestMidConversationOrphanRepair:
"""Mid-conversation orphaned tool_calls get synthetic tool results."""
def test_all_orphaned_mid_conversation(self):
"""Assistant has 2 tool_calls, no tool results, then user message."""
tc = json.dumps(
[
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
{"id": "c2", "function": {"name": "write_file", "arguments": "{}"}},
]
)
rows = [
_row("user", "do stuff"),
_row("assistant", "Running...", tool_calls=tc),
_row("user", "never mind"),
]
msgs = reconstruct_messages(rows, "ws1")
# Should have: user, assistant, tool(c1), tool(c2), user
assert len(msgs) == 5
assert msgs[2]["role"] == "tool"
assert msgs[2]["tool_call_id"] == "c1"
assert msgs[2]["is_error"] is True
assert msgs[3]["role"] == "tool"
assert msgs[3]["tool_call_id"] == "c2"
assert msgs[4]["role"] == "user"
def test_partial_results_mid_conversation(self):
"""2 tool_calls, 1 result present, 1 missing — synthesize only the missing one."""
tc = json.dumps(
[
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
{"id": "c2", "function": {"name": "write_file", "arguments": "{}"}},
]
)
rows = [
_row("user", "do stuff"),
_row("assistant", "", tool_calls=tc),
_row("tool", "file1.txt", tool_name="bash", tc_id="c1"),
_row("user", "skip the write"),
]
msgs = reconstruct_messages(rows, "ws1")
# Should have: user, assistant, tool(c1 real), tool(c2 synthetic), user
assert len(msgs) == 5
assert msgs[2]["role"] == "tool"
assert msgs[2]["tool_call_id"] == "c1"
assert msgs[2]["content"] == "file1.txt"
assert msgs[2].get("is_error") is not True
assert msgs[3]["role"] == "tool"
assert msgs[3]["tool_call_id"] == "c2"
assert msgs[3]["is_error"] is True
assert msgs[4]["role"] == "user"
def test_complete_results_no_synthesis(self):
"""All tool_calls have results — no synthesis needed."""
tc = json.dumps(
[
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
]
)
rows = [
_row("user", "do it"),
_row("assistant", "", tool_calls=tc),
_row("tool", "done", tool_name="bash", tc_id="c1"),
_row("user", "thanks"),
]
msgs = reconstruct_messages(rows, "ws1")
assert len(msgs) == 4
tool_msgs = [m for m in msgs if m["role"] == "tool"]
assert len(tool_msgs) == 1
assert tool_msgs[0].get("is_error") is not True
def test_trailing_orphan_stripped_not_synthesized(self):
"""Trailing orphan is handled by the existing strip repair, not synthesis."""
tc = json.dumps(
[
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
]
)
rows = [
_row("user", "do it"),
_row("assistant", "Running...", tool_calls=tc),
]
msgs = reconstruct_messages(rows, "ws1")
# Trailing strip removes the assistant message entirely
assert len(msgs) == 1
assert msgs[0]["role"] == "user"
class TestSystemTurns:
"""First-class operator-context system turns round-trip and respect repair."""
def test_system_row_reconstructed(self):
rows = [
_row("user", "hi"),
_row("assistant", "hello"),
_row(
"system",
"User sent while you worked: also update the changelog",
source="user_interjection",
),
]
msgs = reconstruct_messages(rows, "ws1")
assert len(msgs) == 3
assert msgs[2] == {
"role": "system",
"content": "User sent while you worked: also update the changelog",
"_source": "user_interjection",
}
def test_developer_row_reconstructed(self):
rows = [
_row("user", "hi"),
_row("assistant", "x"),
_row("developer", "be terse", source="output_guard"),
]
msgs = reconstruct_messages(rows, "ws1")
assert msgs[2]["role"] == "developer"
assert msgs[2]["content"] == "be terse"
assert msgs[2]["_source"] == "output_guard"
def test_system_turn_without_source(self):
# Missing source shouldn't happen for operator context, but must not crash.
rows = [_row("user", "hi"), _row("assistant", "x"), _row("system", "note")]
msgs = reconstruct_messages(rows, "ws1")
assert msgs[2]["role"] == "system"
assert "_source" not in msgs[2]
def test_trailing_system_after_incomplete_assistant_strips_both(self):
"""A nudge appended after an interrupted tool-call turn must not leave
the orphaned assistant — the strip walks through the trailing system
turn (regression guard for the repair-loop fix)."""
tc = json.dumps([{"id": "c1", "function": {"name": "bash", "arguments": "{}"}}])
rows = [
_row("user", "do it"),
_row("assistant", "Running...", tool_calls=tc), # no tool result
_row("system", "tool was cancelled", source="tool_error"),
]
msgs = reconstruct_messages(rows, "ws1")
assert len(msgs) == 1
assert msgs[0]["role"] == "user"
def test_trailing_system_after_complete_turn_kept(self):
"""A system turn after a complete tool batch is preserved (no strip)."""
tc = json.dumps([{"id": "c1", "function": {"name": "bash", "arguments": "{}"}}])
rows = [
_row("user", "do it"),
_row("assistant", "Running...", tool_calls=tc),
_row("tool", "ok", tc_id="c1"),
_row("system", "you repeated a call", source="repeat"),
]
msgs = reconstruct_messages(rows, "ws1")
assert [m["role"] for m in msgs] == ["user", "assistant", "tool", "system"]
def test_trailing_system_after_text_turn_kept(self):
rows = [
_row("user", "hi"),
_row("assistant", "all done"),
_row("system", "wrapping up?", source="completion"),
]
msgs = reconstruct_messages(rows, "ws1")
assert [m["role"] for m in msgs] == ["user", "assistant", "system"]
def test_system_between_tool_use_and_result_not_double_synthesized(self):
"""A system turn between an assistant(tool_calls) and its real result
must not make the answered call look orphaned — pass 2 looks through
system turns, so no duplicate synthetic cancellation is spliced."""
tc = json.dumps([{"id": "c1", "function": {"name": "bash", "arguments": "{}"}}])
rows = [
_row("user", "do it"),
_row("assistant", "", tool_calls=tc),
_row("system", "guard note", source="output_guard"),
_row("tool", "ok", tc_id="c1"),
_row("user", "thanks"),
]
msgs = reconstruct_messages(rows, "ws1")
tool_msgs = [m for m in msgs if m["role"] == "tool"]
assert len(tool_msgs) == 1
assert tool_msgs[0]["tool_call_id"] == "c1"
assert tool_msgs[0].get("is_error") is not True
assert [m["role"] for m in msgs] == ["user", "assistant", "system", "tool", "user"]
def test_synthetic_result_stays_adjacent_to_real_results_past_system(self):
"""When a call IS orphaned and a system turn follows the real results,
the synthetic is inserted adjacent to the real block (before the system
turn), keeping the tool-result block contiguous."""
tc = json.dumps(
[
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
{"id": "c2", "function": {"name": "bash", "arguments": "{}"}},
]
)
rows = [
_row("user", "do it"),
_row("assistant", "", tool_calls=tc),
_row("tool", "ok", tc_id="c1"),
_row("system", "guard note", source="output_guard"),
_row("user", "skip c2"), # c2 never resulted
]
msgs = reconstruct_messages(rows, "ws1")
assert [m["role"] for m in msgs] == [
"user",
"assistant",
"tool",
"tool",
"system",
"user",
]
assert msgs[2]["tool_call_id"] == "c1" and msgs[2].get("is_error") is not True
assert msgs[3]["tool_call_id"] == "c2" and msgs[3]["is_error"] is True