Files
turnstone/tests/test_channel_protocol.py
T
Patrick Buckley 09ea3d164d feat: intent validation v1 — advisory LLM judge for tool approvals (#50) (#50)
* feat: intent validation v1 — advisory LLM judge for tool approvals (#50)

Two-tier evaluation pipeline for non-auto-approved tool calls:
- Heuristic tier (instant): 23 pattern-based rules across 4 severity
  levels (critical/high/medium/low) with first-match-wins priority
- LLM judge tier (async): multi-turn evaluation with read_file/
  list_directory tool access, security-hardened path blocking, forcing
  message on final turn, four-stage JSON parsing with retry nudge

Progressive UI: heuristic verdict badge + judge spinner, LLM verdict
upgrade via intent_verdict SSE event, glow on action buttons. Verdict
persisted to intent_verdicts table for audit. Prometheus metrics for
verdict counts and LLM latency. Enabled by default (--no-judge to opt
out). 132 new tests (1938 total).

Integration: session, server/WebUI, CLI, MQ bridge, console admin API,
Discord channel adapter. Config via [judge] in config.toml or CLI flags.

* fix: address PR #50 Copilot review feedback

- Fix double JSON encoding of func_args in both heuristic and LLM
  verdict persistence paths — use pre-serialized string from verdict
- Fix confidence 0.0 treated as falsy in channel verdict formatter
- Fix timestamp format inconsistency in storage backends (isoformat
  vs strftime) — now uses strftime consistently
- Add on_intent_verdict to eval.py NullUI (mypy fix)
- Fix late verdict after approval resolved — store last decision and
  apply immediately to late-arriving verdicts
- Add permission rollback to migration 012 downgrade
- Update docs to reflect judge enabled by default
- Document confidence_threshold as reserved for v2

* fix: judge per-call timeout and credential recon heuristic

- Wrap create_completion() in ThreadPoolExecutor with per-call timeout
  to prevent indefinite hangs on slow local models. On timeout, replace
  the executor so subsequent batch items don't queue behind lingering
  API calls
- Add IntentJudge.shutdown() and wire into session.close() for cleanup
- Add credential-recon heuristic rule: /etc/passwd, /etc/shadow,
  /etc/master.passwd access flagged as HIGH/review (reconnaissance
  pattern even though the command itself is read-only)
- 3 new tests for credential file access patterns

* fix: denied/blocked tool calls show correct badge on resume

- _build_history() detects denied results ("Denied by user") and
  blocked results ("Blocked") and propagates denied flag to parent
  assistant entry for frontend consumption
- Frontend history replay uses denied flag for badge-denied class
  instead of hardcoding badge-approved for all historical tool calls
- Denial feedback always prefixed with "Denied by user:" so content
  detection works with custom user feedback
- Denied tools visually muted (opacity 0.55, muted tool name)
- role="status" on all approval badge elements (accessibility)
- Broadened "Blocked" prefix match (catches "Blocked by tool policy")
2026-03-13 04:12:46 -07:00

280 lines
9.7 KiB
Python

"""Tests for turnstone.channels._protocol and turnstone.channels._formatter."""
from __future__ import annotations
from turnstone.channels._formatter import (
chunk_message,
format_approval_request,
format_plan_review,
format_verdict,
truncate,
)
from turnstone.channels._protocol import ChannelEvent
# ---------------------------------------------------------------------------
# ChannelEvent
# ---------------------------------------------------------------------------
class TestChannelEvent:
def test_construction(self) -> None:
evt = ChannelEvent(
channel_type="discord",
channel_id="ch-1",
channel_user_id="u-42",
message="hello",
parent_channel_id="parent",
metadata={"key": "val"},
)
assert evt.channel_type == "discord"
assert evt.channel_id == "ch-1"
assert evt.channel_user_id == "u-42"
assert evt.message == "hello"
assert evt.parent_channel_id == "parent"
assert evt.metadata == {"key": "val"}
def test_defaults(self) -> None:
evt = ChannelEvent(
channel_type="slack",
channel_id="ch-2",
channel_user_id="u-7",
message="hi",
)
assert evt.parent_channel_id == ""
assert evt.metadata == {}
def test_metadata_independence(self) -> None:
"""Default metadata dicts are independent across instances."""
a = ChannelEvent(channel_type="x", channel_id="1", channel_user_id="u", message="m")
b = ChannelEvent(channel_type="x", channel_id="2", channel_user_id="u", message="m")
a.metadata["key"] = "val"
assert "key" not in b.metadata
# ---------------------------------------------------------------------------
# chunk_message
# ---------------------------------------------------------------------------
class TestChunkMessage:
def test_empty_string(self) -> None:
assert chunk_message("") == [""]
def test_under_limit(self) -> None:
assert chunk_message("short text", max_length=100) == ["short text"]
def test_exactly_at_limit(self) -> None:
text = "a" * 50
assert chunk_message(text, max_length=50) == [text]
def test_splits_at_newline(self) -> None:
text = "line one\nline two\nline three"
chunks = chunk_message(text, max_length=18)
assert len(chunks) >= 2
# The split should happen at a newline boundary within the text.
# Reassembled chunks (with newline separators) should cover all content.
rejoined = "\n".join(chunks)
assert "line one" in rejoined
assert "line three" in rejoined
def test_splits_at_word_boundary(self) -> None:
text = "word1 word2 word3 word4"
chunks = chunk_message(text, max_length=12)
assert len(chunks) >= 2
# No chunk should start with a space (lstrip handles newlines).
for chunk in chunks:
assert not chunk.startswith("\n")
def test_hard_splits(self) -> None:
text = "a" * 30
chunks = chunk_message(text, max_length=10)
assert len(chunks) == 3
assert "".join(chunks) == text
def test_code_block_spanning_boundary(self) -> None:
text = "before\n```\ncode line 1\ncode line 2\ncode line 3\n```\nafter"
chunks = chunk_message(text, max_length=30)
assert len(chunks) >= 2
# If a chunk opens a code block without closing it, the chunker
# should close it and reopen in the next chunk.
for chunk in chunks:
fence_count = chunk.count("```")
assert fence_count % 2 == 0, f"Unmatched code fence in chunk: {chunk!r}"
def test_multiple_code_blocks(self) -> None:
text = "```\nblock1\n```\ntext\n```\nblock2\n```"
chunks = chunk_message(text, max_length=20)
for chunk in chunks:
fence_count = chunk.count("```")
assert fence_count % 2 == 0, f"Unmatched code fence in chunk: {chunk!r}"
def test_custom_max_length(self) -> None:
text = "hello world"
chunks = chunk_message(text, max_length=5)
assert len(chunks) >= 2
assert chunks[0] == "hello"
def test_very_long_single_line(self) -> None:
text = "x" * 5000
chunks = chunk_message(text, max_length=2000)
assert len(chunks) == 3
total = "".join(chunks)
assert total == text
# ---------------------------------------------------------------------------
# format_approval_request
# ---------------------------------------------------------------------------
class TestFormatApprovalRequest:
def test_single_tool(self) -> None:
items = [{"function": {"name": "read_file", "arguments": "/etc/hosts"}}]
result = format_approval_request(items)
assert "Tool approval required" in result
assert "`read_file`" in result
def test_multiple_tools(self) -> None:
items = [
{"function": {"name": "tool_a", "arguments": "arg1"}},
{"function": {"name": "tool_b", "arguments": "arg2"}},
]
result = format_approval_request(items)
assert "`tool_a`" in result
assert "`tool_b`" in result
def test_long_arguments_truncated(self) -> None:
long_args = "x" * 500
items = [{"function": {"name": "fn", "arguments": long_args}}]
result = format_approval_request(items)
# The result should be shorter than the original args.
assert len(result) < 500
def test_server_sse_format(self) -> None:
"""Items from the server SSE use func_name/preview, not function.name."""
items = [
{
"call_id": "c1",
"func_name": "bash",
"preview": "ls -la",
"header": "Execute: ls -la",
"needs_approval": True,
}
]
result = format_approval_request(items)
assert "`bash`" in result
assert "Execute: ls -la" in result
def test_server_sse_format_no_header(self) -> None:
items = [{"func_name": "read_file", "preview": "/etc/hosts"}]
result = format_approval_request(items)
assert "`read_file`" in result
assert "/etc/hosts" in result
# ---------------------------------------------------------------------------
# format_plan_review
# ---------------------------------------------------------------------------
class TestFormatPlanReview:
def test_format(self) -> None:
result = format_plan_review("Step 1: do stuff")
assert result.startswith("**Plan review requested:**")
assert "Step 1: do stuff" in result
# ---------------------------------------------------------------------------
# format_verdict
# ---------------------------------------------------------------------------
class TestFormatVerdict:
def test_low_risk(self) -> None:
verdict = {
"risk_level": "low",
"recommendation": "allow",
"confidence": 0.95,
"intent_summary": "Reading a config file",
"tier": "heuristic",
}
result = format_verdict(verdict)
assert "HEURISTIC" in result
assert "LOW" in result
assert "95%" in result
assert "allow" in result
assert "_Reading a config file_" in result
# Green circle emoji
assert "\U0001f7e2" in result
def test_high_risk(self) -> None:
verdict = {
"risk_level": "high",
"recommendation": "deny",
"confidence": 0.8,
}
result = format_verdict(verdict)
assert "HIGH" in result
assert "80%" in result
assert "deny" in result
# Red circle emoji
assert "\U0001f534" in result
def test_critical_risk(self) -> None:
verdict = {"risk_level": "critical", "confidence": 0.99}
result = format_verdict(verdict)
assert "CRITICAL" in result
assert "\u26d4" in result
def test_medium_risk_default(self) -> None:
"""Empty risk_level defaults to MEDIUM."""
result = format_verdict({})
assert "MEDIUM" in result
assert "50%" in result
assert "review" in result
def test_no_summary_omits_line(self) -> None:
verdict = {"risk_level": "low", "confidence": 0.7}
result = format_verdict(verdict)
# Should be a single line (no summary italic line).
assert "\n" not in result
def test_with_summary(self) -> None:
verdict = {"risk_level": "low", "intent_summary": "Safe operation"}
result = format_verdict(verdict)
lines = result.split("\n")
assert len(lines) == 2
assert "_Safe operation_" in lines[1]
def test_tier_label(self) -> None:
verdict = {"tier": "llm", "risk_level": "medium"}
result = format_verdict(verdict)
assert "LLM " in result
def test_no_tier_no_label(self) -> None:
verdict = {"risk_level": "low"}
result = format_verdict(verdict)
assert "Risk: LOW" in result
# No double space or extra label prefix.
assert "** " not in result or "**Risk:" in result
# ---------------------------------------------------------------------------
# truncate
# ---------------------------------------------------------------------------
class TestTruncate:
def test_short_text_unchanged(self) -> None:
assert truncate("hello", max_length=200) == "hello"
def test_long_text_truncated(self) -> None:
text = "a" * 300
result = truncate(text, max_length=200)
assert len(result) == 200
assert result.endswith("\u2026")
def test_exactly_at_limit(self) -> None:
text = "b" * 200
assert truncate(text, max_length=200) == text