mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
164f74dead
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.
181 lines
7.0 KiB
Python
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")
|