mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
8513016503
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.
304 lines
12 KiB
Python
304 lines
12 KiB
Python
"""Tests for alembic migration 060 (un-wrap legacy tool-output envelopes).
|
|
|
|
Drives ``command.upgrade`` from a programmatic Alembic config against an
|
|
isolated SQLite database per test, then asserts:
|
|
|
|
* a wrapped ``<tool_output>`` envelope row is rewritten to the bare tool
|
|
output, dropping the embedded ``<system-reminder>`` advisory blocks;
|
|
* only ``&`` → ``&`` is reversed — wrapper-tag entities stay escaped so a
|
|
previously-defanged injection is not re-activated (sec-2);
|
|
* the tightened structural guard requires the *full* envelope signature, so a
|
|
bare row that merely starts with ``<tool_output>`` — or even one with a
|
|
matching ``</tool_output>`` close but no advisory — is left untouched (the
|
|
known-issue #1 false positive);
|
|
* the dead ``_reminders`` side-channel column is dropped outright (not nulled
|
|
and carried forward as a writable foot-gun);
|
|
* the migration is idempotent (a second run is a no-op);
|
|
* a plain non-envelope row is untouched.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import sqlalchemy as sa
|
|
from alembic import command
|
|
from alembic.config import Config
|
|
|
|
_MIGRATIONS_DIR = str(
|
|
Path(__file__).resolve().parent.parent / "turnstone" / "core" / "storage" / "migrations"
|
|
)
|
|
|
|
|
|
def _alembic_cfg(db_path: Path) -> Config:
|
|
cfg = Config()
|
|
cfg.set_main_option("script_location", _MIGRATIONS_DIR)
|
|
cfg.set_main_option("sqlalchemy.url", f"sqlite:///{db_path}")
|
|
return cfg
|
|
|
|
|
|
def _seed_row(conn: sa.Connection, **cols: object) -> None:
|
|
defaults: dict[str, object] = {
|
|
"ws_id": "ws1",
|
|
"timestamp": "2026-06-01T00:00:00",
|
|
"role": "tool",
|
|
"content": None,
|
|
"tool_name": None,
|
|
"tool_call_id": None,
|
|
"provider_data": None,
|
|
"tool_calls": None,
|
|
"_source": None,
|
|
"_reminders": None,
|
|
}
|
|
defaults.update(cols)
|
|
keys = ", ".join(defaults)
|
|
binds = ", ".join(f":{k}" for k in defaults)
|
|
conn.execute(sa.text(f"INSERT INTO conversations ({keys}) VALUES ({binds})"), defaults)
|
|
|
|
|
|
# A wrapped envelope exactly as ``wrap_tool_result`` produced it: the
|
|
# ``<tool_output>`` block, then ``"\n".join`` with a part that itself begins
|
|
# with ``\n<system-reminder>`` — yielding the ``</tool_output>\n\n<system-
|
|
# reminder>`` double-newline join the tightened guard requires.
|
|
_WRAPPED = (
|
|
"<tool_output>\nclean tool output\n</tool_output>\n\n"
|
|
"<system-reminder>\nThe user sent a message. User message: check logs\n</system-reminder>"
|
|
)
|
|
|
|
|
|
class TestMigration060:
|
|
def test_unwraps_envelope_and_drops_advisories(self, tmp_path: Path) -> None:
|
|
db_path = tmp_path / "060-unwrap.db"
|
|
cfg = _alembic_cfg(db_path)
|
|
command.upgrade(cfg, "059")
|
|
|
|
engine = sa.create_engine(f"sqlite:///{db_path}")
|
|
try:
|
|
with engine.begin() as conn:
|
|
_seed_row(conn, content=_WRAPPED, tool_call_id="call_a")
|
|
|
|
command.upgrade(cfg, "060")
|
|
|
|
with engine.connect() as conn:
|
|
content = conn.execute(
|
|
sa.text("SELECT content FROM conversations WHERE tool_call_id = 'call_a'")
|
|
).scalar_one()
|
|
# Envelope stripped to the bare inner output; the advisory block
|
|
# is gone (cosmetic loss accepted by the design).
|
|
assert content == "clean tool output"
|
|
assert "<tool_output>" not in content
|
|
assert "<system-reminder>" not in content
|
|
finally:
|
|
engine.dispose()
|
|
|
|
def test_ampersand_decoded_but_wrapper_tags_left_escaped(self, tmp_path: Path) -> None:
|
|
"""The un-wrap reverses only ``&`` → ``&``. Wrapper-tag entities are
|
|
left escaped on purpose: re-activating ``<system-reminder>`` into a
|
|
live tag would un-defang injection the old escape had neutralised."""
|
|
db_path = tmp_path / "060-decode.db"
|
|
cfg = _alembic_cfg(db_path)
|
|
command.upgrade(cfg, "059")
|
|
|
|
# escape_wrapper_tags(``see <tool_output> & <system-reminder>``) →
|
|
# ``&`` first, then the tag escapes.
|
|
inner_escaped = "see <tool_output> & <system-reminder>"
|
|
wrapped = (
|
|
f"<tool_output>\n{inner_escaped}\n</tool_output>\n\n"
|
|
"<system-reminder>\nThe user sent a message. User message: x\n</system-reminder>"
|
|
)
|
|
|
|
engine = sa.create_engine(f"sqlite:///{db_path}")
|
|
try:
|
|
with engine.begin() as conn:
|
|
_seed_row(conn, content=wrapped, tool_call_id="call_b")
|
|
|
|
command.upgrade(cfg, "060")
|
|
|
|
with engine.connect() as conn:
|
|
content = conn.execute(
|
|
sa.text("SELECT content FROM conversations WHERE tool_call_id = 'call_b'")
|
|
).scalar_one()
|
|
# ``&`` → ``&`` only; the wrapper-tag entities stay escaped.
|
|
assert content == "see <tool_output> & <system-reminder>"
|
|
assert "<system-reminder>" not in content
|
|
finally:
|
|
engine.dispose()
|
|
|
|
def test_literal_prefix_without_close_untouched(self, tmp_path: Path) -> None:
|
|
"""A tool output that merely STARTS with a literal ``<tool_output>``
|
|
line but has no matching close is not an envelope — left untouched."""
|
|
db_path = tmp_path / "060-prefix.db"
|
|
cfg = _alembic_cfg(db_path)
|
|
command.upgrade(cfg, "059")
|
|
|
|
unmatched = "<tool_output>\nthis tool printed the open tag but never closed it"
|
|
|
|
engine = sa.create_engine(f"sqlite:///{db_path}")
|
|
try:
|
|
with engine.begin() as conn:
|
|
_seed_row(conn, content=unmatched, tool_call_id="call_c")
|
|
|
|
command.upgrade(cfg, "060")
|
|
|
|
with engine.connect() as conn:
|
|
content = conn.execute(
|
|
sa.text("SELECT content FROM conversations WHERE tool_call_id = 'call_c'")
|
|
).scalar_one()
|
|
assert content == unmatched
|
|
finally:
|
|
engine.dispose()
|
|
|
|
def test_tool_output_open_close_without_advisory_untouched(self, tmp_path: Path) -> None:
|
|
"""The known-issue #1 false positive: a bare tool output that genuinely
|
|
starts with ``<tool_output>`` AND has a matching ``</tool_output>`` close
|
|
but NO trailing ``<system-reminder>`` advisory is NOT a legacy envelope
|
|
(those were only emitted with advisories). The tightened guard leaves
|
|
it byte-for-byte untouched — the loose open+close guard would have
|
|
irreversibly mis-rewritten it to its inner text."""
|
|
db_path = tmp_path / "060-noadvisory.db"
|
|
cfg = _alembic_cfg(db_path)
|
|
command.upgrade(cfg, "059")
|
|
|
|
# e.g. a tool that printed XML, or this project's own source/docs.
|
|
bare = "<tool_output>\nls -la output here\n</tool_output>\nplus a trailing line"
|
|
|
|
engine = sa.create_engine(f"sqlite:///{db_path}")
|
|
try:
|
|
with engine.begin() as conn:
|
|
_seed_row(conn, content=bare, tool_call_id="call_g")
|
|
|
|
command.upgrade(cfg, "060")
|
|
|
|
with engine.connect() as conn:
|
|
content = conn.execute(
|
|
sa.text("SELECT content FROM conversations WHERE tool_call_id = 'call_g'")
|
|
).scalar_one()
|
|
assert content == bare
|
|
finally:
|
|
engine.dispose()
|
|
|
|
def test_missing_trailing_system_reminder_close_untouched(self, tmp_path: Path) -> None:
|
|
"""An open + join that lacks the trailing ``</system-reminder>`` close is
|
|
not a complete envelope — left untouched rather than half-rewritten."""
|
|
db_path = tmp_path / "060-notail.db"
|
|
cfg = _alembic_cfg(db_path)
|
|
command.upgrade(cfg, "059")
|
|
|
|
truncated = "<tool_output>\nx\n</tool_output>\n\n<system-reminder>\nno close here"
|
|
|
|
engine = sa.create_engine(f"sqlite:///{db_path}")
|
|
try:
|
|
with engine.begin() as conn:
|
|
_seed_row(conn, content=truncated, tool_call_id="call_h")
|
|
|
|
command.upgrade(cfg, "060")
|
|
|
|
with engine.connect() as conn:
|
|
content = conn.execute(
|
|
sa.text("SELECT content FROM conversations WHERE tool_call_id = 'call_h'")
|
|
).scalar_one()
|
|
assert content == truncated
|
|
finally:
|
|
engine.dispose()
|
|
|
|
def test_drops_reminders_column(self, tmp_path: Path) -> None:
|
|
db_path = tmp_path / "060-reminders.db"
|
|
cfg = _alembic_cfg(db_path)
|
|
command.upgrade(cfg, "059")
|
|
|
|
engine = sa.create_engine(f"sqlite:///{db_path}")
|
|
try:
|
|
with engine.begin() as conn:
|
|
# The column still exists at 059, so a legacy value can be seeded.
|
|
_seed_row(
|
|
conn,
|
|
role="user",
|
|
content="hello",
|
|
tool_call_id="call_d",
|
|
_reminders='[{"type":"correction","text":"watch it"}]',
|
|
)
|
|
# At 059 the column is present.
|
|
assert "_reminders" in {
|
|
c["name"] for c in sa.inspect(engine).get_columns("conversations")
|
|
}
|
|
|
|
command.upgrade(cfg, "060")
|
|
|
|
# 060 drops it outright (no dead column carried forward); the row
|
|
# itself survives.
|
|
cols = {c["name"] for c in sa.inspect(engine).get_columns("conversations")}
|
|
assert "_reminders" not in cols
|
|
assert "_source" in cols # the live sibling stays
|
|
with engine.connect() as conn:
|
|
content = conn.execute(
|
|
sa.text("SELECT content FROM conversations WHERE tool_call_id = 'call_d'")
|
|
).scalar_one()
|
|
assert content == "hello"
|
|
finally:
|
|
engine.dispose()
|
|
|
|
def test_non_envelope_row_untouched(self, tmp_path: Path) -> None:
|
|
db_path = tmp_path / "060-plain.db"
|
|
cfg = _alembic_cfg(db_path)
|
|
command.upgrade(cfg, "059")
|
|
|
|
engine = sa.create_engine(f"sqlite:///{db_path}")
|
|
try:
|
|
with engine.begin() as conn:
|
|
_seed_row(conn, content="just a normal tool result", tool_call_id="call_e")
|
|
|
|
command.upgrade(cfg, "060")
|
|
|
|
with engine.connect() as conn:
|
|
content = conn.execute(
|
|
sa.text("SELECT content FROM conversations WHERE tool_call_id = 'call_e'")
|
|
).scalar_one()
|
|
assert content == "just a normal tool result"
|
|
finally:
|
|
engine.dispose()
|
|
|
|
def test_idempotent(self, tmp_path: Path) -> None:
|
|
"""A second pass over the now-clean rows is a no-op.
|
|
|
|
Alembic won't re-run a stamped revision, so the rewrite's
|
|
stability is asserted directly on the migration's ``_unwrap_envelope``
|
|
guard: a bare (already-unwrapped) output is not an envelope, so a
|
|
second pass leaves it alone.
|
|
"""
|
|
db_path = tmp_path / "060-idem.db"
|
|
cfg = _alembic_cfg(db_path)
|
|
command.upgrade(cfg, "059")
|
|
|
|
engine = sa.create_engine(f"sqlite:///{db_path}")
|
|
try:
|
|
with engine.begin() as conn:
|
|
_seed_row(conn, content=_WRAPPED, tool_call_id="call_f")
|
|
|
|
command.upgrade(cfg, "060")
|
|
with engine.connect() as conn:
|
|
first = conn.execute(
|
|
sa.text("SELECT content FROM conversations WHERE tool_call_id = 'call_f'")
|
|
).scalar_one()
|
|
finally:
|
|
engine.dispose()
|
|
|
|
# The migration module's filename (``060_...``) isn't a valid import
|
|
# identifier, so load it by path to reuse its guard.
|
|
import importlib.util
|
|
|
|
mig_path = (
|
|
Path(__file__).resolve().parent.parent
|
|
/ "turnstone"
|
|
/ "core"
|
|
/ "storage"
|
|
/ "migrations"
|
|
/ "versions"
|
|
/ "060_unwrap_tool_envelopes.py"
|
|
)
|
|
spec = importlib.util.spec_from_file_location("_mig_060", mig_path)
|
|
assert spec is not None and spec.loader is not None
|
|
mig = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(mig)
|
|
# Already-clean content is not an envelope → second pass is a no-op.
|
|
assert mig._unwrap_envelope(first) is None
|