mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-27 22:34:51 -06:00
a6e929b0a0
* Add channel integrations with Discord adapter and atomic session resume (#24) Bidirectional channel adapter framework connecting external messaging platforms to turnstone workstreams via Redis MQ. Discord ships as the first adapter; the protocol supports future Slack/Teams integrations. Channel framework: - ChannelAdapter protocol and ChannelRouter for channel↔workstream mapping - AsyncRedisBroker with single dispatch loop and per-channel ordered workers - channel_routes table (migration 003) for persistent route storage - 9 new StorageBackend methods (4 channel_user + 5 channel_route CRUD) - Unified turnstone-channel gateway entry point, loads adapters by config - Message chunking, approval formatting, plan review formatting Discord adapter: - discord.py v2.4+ bot with thread-per-@mention model - Slash commands: /link (modal), /unlink, /ask, /status, /close - Persistent button views for tool approval and plan review - Streaming responses via edit-in-place (1.5s interval) - Stale route detection and atomic session resume via resume_session field - SessionResumedEvent confirmation back to channel - Auto-approve support (blanket + per-tool list) Atomic session resume: - resume_session field on CreateWorkstreamMessage for single-request resume - Server resumes session during POST /v1/api/workstreams/new atomically - Bridge emits SessionResumedEvent to per-workstream channel - WorkstreamCreatedEvent extended with resumed/session_id/message_count - Server UI dashboardResumeSession simplified to single request - Pruned sessions fall back gracefully to fresh start Service auth: - Bridge and console auto-mint service JWTs from TURNSTONE_JWT_SECRET - Bridge: approve scope (1 week). Console collector: read. Proxy: write. Console admin: - Channels tab with per-user view, force-link modal, unlink - 3 admin API endpoints for channel user management - Styled confirm modals replacing browser confirm() dialogs Bug fixes: - AsyncRedisBroker: replaced per-channel listener tasks with single dispatch loop + per-channel queue workers (fixes message stealing race) - Bridge: approval/plan review dedup guard prevents SSE reconnect duplicates - Bridge: _active_sends tracked for initial messages (fixes missing TurnCompleteEvent and unfinalized streaming messages) - Bridge: HTTP calls moved outside lock scope in approval handlers - Bridge: _handle_send cleans up _active_sends on HTTP/server errors - Formatter: reads server SSE format (func_name/preview) with fallback Docs, SDK, tests: - docs/channels.md setup guide, architecture diagram 16 - Updated api-reference.md, architecture.md, console.md, docker.md - Python SDK: resume_session param on create_workstream (async + sync) - TypeScript SDK: updated CreateWorkstreamRequest/Response interfaces - OpenAPI schema: resume_session request, resumed/message_count response - 91 new tests (19 storage, 15 broker, 22 protocol, 6 routing, 18 discord, 12 resume flow) — 1120 total passing * Fix CI lint/typecheck failures and address Copilot review feedback (#24) Lint: fix import ordering, remove unused imports, use contextlib.suppress. Mypy: explicit postgresql dialect import, add discord module overrides for optional-dependency CI environments. Copilot: fix double-escaping in admin confirm modals, return resolved session_id from server resume response, fix channel_routes diagram schema, use atomic setdefault for routing locks, add post-insert race guard in admin channel create, support SSE format in auto-approve check, update identity linking note in architecture diagram. * Fix remaining mypy call-arg errors for discord.py optional dependency Add type: ignore[call-arg] on Modal(title=) and Cog(name=) class definitions that fail when discord.py is not installed in CI.
204 lines
7.2 KiB
Python
204 lines
7.2 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,
|
|
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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|