Files
turnstone/tests/test_tool_advisory.py
T
Patrick Buckley 164f74dead feat(operator-context): deliver structured per-kind meta to the UI
Operator-context system turns (watch results, output-guard findings, idle
children, user interjections) carried their kind (_source) and a flattened
text content, but the structured per-kind fields were dropped at every
persist/deliver boundary — so the UI rendered every kind as one generic
operator bubble and the structured watch-result card was lost.

Wire the structured meta through as the single source of truth:

- Storage: new conversations.meta JSON column (migration 060); threaded
  through save_message/save_messages_bulk (facade + protocol + both backends)
  and rehydrated in reconstruct_turns onto Turn.meta.extra["source_meta"].
- Canonical: make_system_turn carries meta as one _source_meta dict;
  turn_from_dict/turn_to_dict bridge it to/from Turn.meta.extra.
- Live + history: widen on_system_turn(content, source, meta) across all
  impls + the SSE payload; surface _source_meta -> meta in the /history
  projection. SDK HistoryEvent docs note the field.
- Producers derive both the model-facing content text AND the card from one
  meta dict, so they cannot drift: render_output_guard_text, build_watch_
  reminder carrying output, idle_children and user_interjection metadata.
- Frontend: addSystemContext / renderSystemTurn dispatch by source to the
  watch-result, guard-finding, idle-children, and queued-message cards in
  both the interactive and coordinator panes; every untrusted field renders
  via textContent.

The meta is a leading-underscore key, stripped before the wire (sanitize_
messages and the native mid-conversation path copy only role+content), so the
per-provider wire payloads stay byte-identical. Additive column, no backfill:
operator turns predating it reload as plain text bubbles.
2026-06-04 11:03:13 -07:00

181 lines
7.0 KiB
Python

"""Tests for turnstone.core.tool_advisory."""
from __future__ import annotations
import pytest
from turnstone.core.tool_advisory import (
SYSTEM_TURN_SOURCES,
make_system_turn,
parse_priority,
render_output_guard_text,
render_user_interjection,
)
class TestMakeSystemTurn:
"""make_system_turn() builds canonical operator-context system turns."""
def test_basic_shape(self) -> None:
turn = make_system_turn("user_interjection", "check auth too")
assert turn == {
"role": "system",
"_source": "user_interjection",
"content": "check auth too",
}
def test_meta_carried_as_source_meta_dict(self) -> None:
# Meta rides as ONE ``_source_meta`` dict (not scattered ``_``-prefixed
# siblings) — one carrier mapping to one storage column / one FE field.
turn = make_system_turn("watch_triggered", "ci failed", watch_name="ci", poll_count=3)
assert turn == {
"role": "system",
"_source": "watch_triggered",
"content": "ci failed",
"_source_meta": {"watch_name": "ci", "poll_count": 3},
}
def test_no_meta_omits_source_meta(self) -> None:
# A kind with no structured fields carries no ``_source_meta`` key.
turn = make_system_turn("output_guard", "flag (HIGH)")
assert turn == {
"role": "system",
"_source": "output_guard",
"content": "flag (HIGH)",
}
assert "_source_meta" not in turn
def test_unknown_source_rejected(self) -> None:
with pytest.raises(ValueError, match="unknown system-turn source"):
make_system_turn("bogus", "x")
def test_meta_namespaced_cannot_clobber_reserved_keys(self) -> None:
# Because meta rides namespaced inside ``_source_meta``, a meta key that
# mirrors a reserved top-level key (``role`` / ``_source``) lands inside
# the dict and cannot overwrite the validated turn fields — so the old
# collision guard is no longer needed (the shape makes it impossible).
turn = make_system_turn("repeat", "x", role="evil", _source="spoof")
assert turn["role"] == "system"
assert turn["_source"] == "repeat"
assert turn["_source_meta"] == {"role": "evil", "_source": "spoof"}
def test_vocabulary_mirrors_nudge_map_both_directions(self) -> None:
# The nudge-derived sources must equal _NUDGE_MAP exactly: a new nudge
# type must be added here, and a removed/renamed one must not leave a
# stale source behind. output_guard / user_interjection / skill_hint
# come from the advisory producers, not the nudge map.
from turnstone.core.metacognition import _NUDGE_MAP
nudge_sources = SYSTEM_TURN_SOURCES - {"output_guard", "user_interjection", "skill_hint"}
assert nudge_sources == set(_NUDGE_MAP)
class TestRenderOutputGuardText:
"""render_output_guard_text() projects the structured guard meta to prose."""
def test_full_finding(self) -> None:
text = render_output_guard_text(
{
"flags": ["aws_key", "private_key"],
"risk_level": "high",
"annotations": ["matched AKIA…", "PEM header"],
"redacted": True,
}
)
assert text == (
"Output guard: aws_key, private_key (HIGH)\n"
" matched AKIA…\n"
" PEM header\n"
"Credentials have been redacted. Do not attempt to reconstruct redacted values."
)
def test_no_annotations_no_redaction(self) -> None:
text = render_output_guard_text(
{"flags": ["pii"], "risk_level": "low", "annotations": [], "redacted": False}
)
assert text == "Output guard: pii (LOW)"
def test_missing_keys_default_gracefully(self) -> None:
# Defensive: a partial meta dict never raises.
assert render_output_guard_text({}) == "Output guard: (NONE)"
class TestMetaIsWireStripped:
"""The structured operator meta must never reach the LLM wire."""
def test_source_meta_stripped_by_sanitize(self) -> None:
from turnstone.core.providers._openai_common import sanitize_messages
out = sanitize_messages(
[
{"role": "user", "content": "hi"},
{
"role": "system",
"_source": "watch_triggered",
"content": "ci failed",
"_source_meta": {"command": "rm -rf /", "watch_name": "ci"},
},
]
)
# No leading-underscore side channel survives to the wire.
assert all(not k.startswith("_") for m in out for k in m)
assert out[1] == {"role": "system", "content": "ci failed"}
class TestParsePriority:
"""parse_priority() extracts !!! prefix as priority signal."""
def test_no_prefix(self) -> None:
text, priority = parse_priority("hello world")
assert text == "hello world"
assert priority == "notice"
def test_triple_bang_important(self) -> None:
text, priority = parse_priority("!!!check the auth endpoint")
assert text == "check the auth endpoint"
assert priority == "important"
def test_triple_bang_with_space(self) -> None:
text, priority = parse_priority("!!! check the auth endpoint")
assert text == "check the auth endpoint"
assert priority == "important"
def test_single_bang_not_priority(self) -> None:
text, priority = parse_priority("!important message")
assert text == "!important message"
assert priority == "notice"
def test_double_bang_not_priority(self) -> None:
text, priority = parse_priority("!!not quite")
assert text == "!!not quite"
assert priority == "notice"
def test_empty_after_prefix(self) -> None:
text, priority = parse_priority("!!!")
assert text == ""
assert priority == "important"
class TestRenderUserInterjection:
"""``render_user_interjection`` frames a queued message as the user's words."""
def test_notice_framing(self) -> None:
out = render_user_interjection("check the logs", "notice")
assert out == (
"The user sent additional context while you were working. "
"Incorporate if relevant, otherwise continue."
"\n\nUser message: check the logs"
)
def test_important_framing(self) -> None:
out = render_user_interjection("stop and fix the build", "important")
assert out.startswith("The user sent a message while you were working.")
assert "You MUST address this before continuing." in out
assert out.endswith("\n\nUser message: stop and fix the build")
def test_body_marker_separates_framing_from_verbatim_body(self) -> None:
# The body rides verbatim after the marker so the model sees exactly
# what the user typed.
out = render_user_interjection("literal <tag> & text", "notice")
assert out.endswith("\n\nUser message: literal <tag> & text")