mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-15 16:32:26 -06:00
205e7818f8
* fix: replace empty except blocks with diagnostic logging
Add log.debug/warning to 7 bare except-pass blocks that silenced
failures in security-relevant or operationally-important paths:
- Channel route lookup, CLI policy evaluation, OIDC JWKS fetch,
prompt policy loading, plan file write, routing override, username
resolution.
Plan write now reports failure to user instead of falsely claiming
"Plan saved."
* fix: replace assert-with-side-effect and narrow BaseException catch
- Convert 4 assert isinstance() to explicit TypeError raises — assertions
are stripped under python -O, removing runtime type checks
- Narrow except BaseException to except Exception in fallback handler —
KeyboardInterrupt/SystemExit should not record as health failures
- Plan write failure now reports error to user instead of "Plan saved"
* fix: wire up toast error type and remove useless conditional
- showToast() now accepts optional type param ("error") with red border
styling — 3 call sites were passing "error" that was silently ignored
- Remove always-true if (q) guard after early-return on empty query
* fix: remove unreachable return None after return self._judge
* fix: parenthesize multi-line string concatenations in dev_parts list
Explicit parens make intentional concatenation unambiguous to static
analysis (CodeQL implicit-string-concatenation-in-list rule).
* fix: remove constant-true filter in test mock — return list directly
* fix: extract side-effecting calls from assert in tests
store.delete() and mgr.close() have side effects that would be
stripped under python -O. Assign to variable first, then assert.
* fix: remove unused local variables in tests
Drop assignments to unused workstream/variable references created
solely for side effects. Use _ for unused tuple unpacking.
* fix: use admin.prompt_policies permission for prompt policy endpoints
All 5 prompt-policy endpoints (list, create, get, update, delete)
were checking admin.policies (the tool-policy permission) instead of
admin.prompt_policies. This caused a mismatch with the admin UI which
gates the tab on admin.prompt_policies — users could see the tab but
get 403, or reach the endpoint but never see the tab.
* fix: use caplog instead of capsys for structlog warning assertion
structlog output goes through the logging system, not stdout/stderr.
* fix: address review — remove dead isinstance, module-level import, unnecessary lambdas
- session.py: remove unreachable isinstance check (has_batch already
validates raw_edits is a list)
- cli.py: move logging import to module level
- test_workstream.py: replace lambda wid: FakeUI(wid) with FakeUI
1019 lines
34 KiB
Python
1019 lines
34 KiB
Python
"""Tests for turnstone.core.workstream — WorkstreamManager, state management, and UI adapters."""
|
|
|
|
import threading
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from turnstone.core.workstream import Workstream, WorkstreamManager, WorkstreamState
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class FakeSession:
|
|
"""Minimal stand-in for ChatSession in workstream tests."""
|
|
|
|
def __init__(self):
|
|
self.model = "test-model"
|
|
self.messages = []
|
|
|
|
|
|
def _fake_factory(ui, model_alias=None, ws_id=None, **kwargs):
|
|
return FakeSession()
|
|
|
|
|
|
class FakeUI:
|
|
"""Minimal SessionUI that tracks state changes."""
|
|
|
|
def __init__(self, ws_id=""):
|
|
self.ws_id = ws_id
|
|
self.state_changes = []
|
|
self.auto_approve = False
|
|
|
|
def on_state_change(self, state):
|
|
self.state_changes.append(state)
|
|
|
|
# Stubs for the rest of the protocol
|
|
def on_thinking_start(self):
|
|
pass
|
|
|
|
def on_thinking_stop(self):
|
|
pass
|
|
|
|
def on_reasoning_token(self, text):
|
|
pass
|
|
|
|
def on_content_token(self, text):
|
|
pass
|
|
|
|
def on_stream_end(self):
|
|
pass
|
|
|
|
def approve_tools(self, items):
|
|
return True, None
|
|
|
|
def on_tool_result(self, call_id, name, output, **kwargs):
|
|
pass
|
|
|
|
def on_tool_output_chunk(self, call_id, chunk):
|
|
pass
|
|
|
|
def on_status(self, usage, context_window, effort):
|
|
pass
|
|
|
|
def on_plan_review(self, content):
|
|
return ""
|
|
|
|
def on_info(self, message):
|
|
pass
|
|
|
|
def on_error(self, message):
|
|
pass
|
|
|
|
def on_rename(self, name):
|
|
pass
|
|
|
|
def on_output_warning(self, call_id, assessment):
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WorkstreamState enum
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestWorkstreamState:
|
|
def test_values(self):
|
|
assert WorkstreamState.IDLE.value == "idle"
|
|
assert WorkstreamState.THINKING.value == "thinking"
|
|
assert WorkstreamState.RUNNING.value == "running"
|
|
assert WorkstreamState.ATTENTION.value == "attention"
|
|
assert WorkstreamState.ERROR.value == "error"
|
|
|
|
def test_from_string(self):
|
|
assert WorkstreamState("idle") == WorkstreamState.IDLE
|
|
assert WorkstreamState("attention") == WorkstreamState.ATTENTION
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Workstream dataclass
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestWorkstream:
|
|
def test_default_name(self):
|
|
ws = Workstream()
|
|
assert ws.name.startswith("ws-")
|
|
assert len(ws.name) == 7 # "ws-" + 4 hex chars
|
|
|
|
def test_custom_name(self):
|
|
ws = Workstream(name="my-stream")
|
|
assert ws.name == "my-stream"
|
|
|
|
def test_default_state(self):
|
|
ws = Workstream()
|
|
assert ws.state == WorkstreamState.IDLE
|
|
|
|
def test_id_uniqueness(self):
|
|
ws1 = Workstream()
|
|
ws2 = Workstream()
|
|
assert ws1.id != ws2.id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WorkstreamManager — creation and lookup
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestManagerCreation:
|
|
def test_create_first_sets_active(self):
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
ws = mgr.create(ui_factory=FakeUI)
|
|
assert mgr.active_id == ws.id
|
|
assert mgr.get_active() is ws
|
|
|
|
def test_create_second_does_not_change_active(self):
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
ws1 = mgr.create(ui_factory=FakeUI)
|
|
mgr.create(ui_factory=FakeUI)
|
|
assert mgr.active_id == ws1.id
|
|
|
|
def test_create_assigns_session(self):
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
ws = mgr.create(ui_factory=FakeUI)
|
|
assert isinstance(ws.session, FakeSession)
|
|
|
|
def test_create_assigns_ui(self):
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
ws = mgr.create(ui_factory=FakeUI)
|
|
assert isinstance(ws.ui, FakeUI)
|
|
assert ws.ui.ws_id == ws.id
|
|
|
|
def test_create_custom_name(self):
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
ws = mgr.create(name="research", ui_factory=FakeUI)
|
|
assert ws.name == "research"
|
|
|
|
def test_create_default_name(self):
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
ws = mgr.create(ui_factory=FakeUI)
|
|
assert ws.name.startswith("ws-")
|
|
|
|
def test_create_max_workstreams_all_active(self):
|
|
mgr = WorkstreamManager(_fake_factory, max_workstreams=3)
|
|
ws1 = mgr.create(ui_factory=FakeUI)
|
|
ws2 = mgr.create(ui_factory=FakeUI)
|
|
ws3 = mgr.create(ui_factory=FakeUI)
|
|
# Mark all as non-idle so eviction cannot help
|
|
mgr.set_state(ws1.id, WorkstreamState.THINKING)
|
|
mgr.set_state(ws2.id, WorkstreamState.RUNNING)
|
|
mgr.set_state(ws3.id, WorkstreamState.ATTENTION)
|
|
with pytest.raises(RuntimeError, match="All 3 workstreams are active"):
|
|
mgr.create(ui_factory=FakeUI)
|
|
|
|
|
|
class TestManagerLookup:
|
|
def test_get_existing(self):
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
ws = mgr.create(ui_factory=FakeUI)
|
|
assert mgr.get(ws.id) is ws
|
|
|
|
def test_get_nonexistent(self):
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
assert mgr.get("no-such-id") is None
|
|
|
|
def test_list_all_creation_order(self):
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
mgr.create(name="a", ui_factory=FakeUI)
|
|
mgr.create(name="b", ui_factory=FakeUI)
|
|
mgr.create(name="c", ui_factory=FakeUI)
|
|
result = mgr.list_all()
|
|
assert [w.name for w in result] == ["a", "b", "c"]
|
|
|
|
def test_index_of(self):
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
ws1 = mgr.create(ui_factory=FakeUI)
|
|
ws2 = mgr.create(ui_factory=FakeUI)
|
|
assert mgr.index_of(ws1.id) == 1
|
|
assert mgr.index_of(ws2.id) == 2
|
|
assert mgr.index_of("nonexistent") == 0
|
|
|
|
def test_count(self):
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
assert mgr.count == 0
|
|
mgr.create(ui_factory=FakeUI)
|
|
assert mgr.count == 1
|
|
mgr.create(ui_factory=FakeUI)
|
|
assert mgr.count == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WorkstreamManager — switching
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestManagerSwitching:
|
|
def test_switch_by_id(self):
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
ws1 = mgr.create(ui_factory=FakeUI)
|
|
ws2 = mgr.create(ui_factory=FakeUI)
|
|
assert mgr.active_id == ws1.id
|
|
|
|
result = mgr.switch(ws2.id)
|
|
assert result is ws2
|
|
assert mgr.active_id == ws2.id
|
|
|
|
def test_switch_nonexistent_returns_none(self):
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
mgr.create(ui_factory=FakeUI)
|
|
assert mgr.switch("bad-id") is None
|
|
|
|
def test_switch_by_index(self):
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
mgr.create(ui_factory=FakeUI)
|
|
ws2 = mgr.create(ui_factory=FakeUI)
|
|
|
|
result = mgr.switch_by_index(2)
|
|
assert result is ws2
|
|
assert mgr.active_id == ws2.id
|
|
|
|
def test_switch_by_index_out_of_range(self):
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
mgr.create(ui_factory=FakeUI)
|
|
assert mgr.switch_by_index(0) is None
|
|
assert mgr.switch_by_index(5) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WorkstreamManager — closing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestManagerClose:
|
|
def test_close_removes_workstream(self):
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
mgr.create(ui_factory=FakeUI)
|
|
ws2 = mgr.create(ui_factory=FakeUI)
|
|
|
|
closed = mgr.close(ws2.id)
|
|
assert closed is True
|
|
assert mgr.count == 1
|
|
assert mgr.get(ws2.id) is None
|
|
|
|
def test_close_last_returns_false(self):
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
ws = mgr.create(ui_factory=FakeUI)
|
|
closed = mgr.close(ws.id)
|
|
assert closed is False
|
|
assert mgr.count == 1
|
|
|
|
def test_close_nonexistent_returns_false(self):
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
mgr.create(ui_factory=FakeUI)
|
|
mgr.create(ui_factory=FakeUI)
|
|
closed = mgr.close("nonexistent")
|
|
assert closed is False
|
|
|
|
def test_close_active_switches_to_first(self):
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
ws1 = mgr.create(ui_factory=FakeUI)
|
|
ws2 = mgr.create(ui_factory=FakeUI)
|
|
mgr.switch(ws2.id)
|
|
|
|
mgr.close(ws2.id)
|
|
assert mgr.active_id == ws1.id
|
|
|
|
def test_close_updates_order(self):
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
mgr.create(name="a", ui_factory=FakeUI)
|
|
ws2 = mgr.create(name="b", ui_factory=FakeUI)
|
|
mgr.create(name="c", ui_factory=FakeUI)
|
|
|
|
mgr.close(ws2.id)
|
|
names = [w.name for w in mgr.list_all()]
|
|
assert names == ["a", "c"]
|
|
|
|
def test_close_unblocks_approval_event(self):
|
|
"""Closing a workstream whose UI has a pending approval should unblock it."""
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
mgr.create(ui_factory=FakeUI)
|
|
|
|
# Create a workstream with a WebUI-like approval mechanism
|
|
from turnstone.server import WebUI
|
|
|
|
ws2 = mgr.create(ui_factory=lambda wid: WebUI(ws_id=wid))
|
|
ws2.ui._approval_event.clear() # simulate pending approval
|
|
|
|
mgr.close(ws2.id)
|
|
# The approval event should be set (unblocked)
|
|
assert ws2.ui._approval_event.is_set()
|
|
|
|
def test_close_unblocks_plan_event(self):
|
|
"""Closing a workstream with pending plan review should unblock it."""
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
mgr.create(ui_factory=FakeUI)
|
|
|
|
from turnstone.server import WebUI
|
|
|
|
ws2 = mgr.create(ui_factory=lambda wid: WebUI(ws_id=wid))
|
|
ws2.ui._plan_event.clear()
|
|
|
|
mgr.close(ws2.id)
|
|
assert ws2.ui._plan_event.is_set()
|
|
assert ws2.ui._plan_result == "reject"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WorkstreamManager — auto-eviction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestManagerEviction:
|
|
def test_evict_oldest_idle_on_create(self):
|
|
"""At capacity with idle workstreams, create() succeeds by evicting the oldest idle."""
|
|
mgr = WorkstreamManager(_fake_factory, max_workstreams=3)
|
|
ws1 = mgr.create(name="oldest", ui_factory=FakeUI)
|
|
ws2 = mgr.create(name="middle", ui_factory=FakeUI)
|
|
mgr.create(name="newest", ui_factory=FakeUI)
|
|
# All three are IDLE. Mark ws2 as RUNNING so it won't be evicted.
|
|
mgr.set_state(ws2.id, WorkstreamState.RUNNING)
|
|
# ws1 is oldest idle, ws3 is newer idle. Creating should evict ws1.
|
|
ws4 = mgr.create(name="four", ui_factory=FakeUI)
|
|
assert mgr.count == 3
|
|
assert mgr.get(ws1.id) is None, "oldest idle should have been evicted"
|
|
assert mgr.get(ws4.id) is ws4
|
|
# Creation order should reflect the eviction
|
|
names = [w.name for w in mgr.list_all()]
|
|
assert "oldest" not in names
|
|
assert names == ["middle", "newest", "four"]
|
|
|
|
def test_create_fails_when_all_active(self):
|
|
"""At capacity with ALL non-idle workstreams, create() raises RuntimeError."""
|
|
mgr = WorkstreamManager(_fake_factory, max_workstreams=2)
|
|
ws1 = mgr.create(ui_factory=FakeUI)
|
|
ws2 = mgr.create(ui_factory=FakeUI)
|
|
mgr.set_state(ws1.id, WorkstreamState.THINKING)
|
|
mgr.set_state(ws2.id, WorkstreamState.RUNNING)
|
|
with pytest.raises(RuntimeError, match="All 2 workstreams are active"):
|
|
mgr.create(ui_factory=FakeUI)
|
|
|
|
def test_configurable_max(self):
|
|
"""Constructor accepts max_workstreams param and respects it."""
|
|
mgr = WorkstreamManager(_fake_factory, max_workstreams=2)
|
|
ws1 = mgr.create(ui_factory=FakeUI)
|
|
ws2 = mgr.create(ui_factory=FakeUI)
|
|
mgr.set_state(ws1.id, WorkstreamState.RUNNING)
|
|
mgr.set_state(ws2.id, WorkstreamState.RUNNING)
|
|
with pytest.raises(RuntimeError):
|
|
mgr.create(ui_factory=FakeUI)
|
|
assert mgr.count == 2
|
|
|
|
def test_eviction_counter(self):
|
|
"""eviction_count increments on each auto-eviction."""
|
|
mgr = WorkstreamManager(_fake_factory, max_workstreams=2)
|
|
assert mgr.eviction_count == 0
|
|
mgr.create(ui_factory=FakeUI)
|
|
mgr.create(ui_factory=FakeUI)
|
|
# Both IDLE — create should evict the oldest
|
|
mgr.create(ui_factory=FakeUI)
|
|
assert mgr.eviction_count == 1
|
|
# Again — evict another idle one
|
|
mgr.create(ui_factory=FakeUI)
|
|
assert mgr.eviction_count == 2
|
|
assert mgr.count == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WorkstreamManager — state management
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestManagerState:
|
|
def test_set_state(self):
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
ws = mgr.create(ui_factory=FakeUI)
|
|
assert ws.state == WorkstreamState.IDLE
|
|
|
|
mgr.set_state(ws.id, WorkstreamState.THINKING)
|
|
assert ws.state == WorkstreamState.THINKING
|
|
|
|
def test_set_state_with_error(self):
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
ws = mgr.create(ui_factory=FakeUI)
|
|
|
|
mgr.set_state(ws.id, WorkstreamState.ERROR, error_msg="API timeout")
|
|
assert ws.state == WorkstreamState.ERROR
|
|
assert ws.error_message == "API timeout"
|
|
|
|
def test_set_state_nonexistent_is_noop(self):
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
mgr.set_state("no-such-id", WorkstreamState.THINKING) # should not raise
|
|
|
|
def test_on_state_change_callback(self):
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
ws = mgr.create(ui_factory=FakeUI)
|
|
|
|
changes = []
|
|
mgr._on_state_change = lambda wid, state: changes.append((wid, state))
|
|
|
|
mgr.set_state(ws.id, WorkstreamState.RUNNING)
|
|
assert changes == [(ws.id, WorkstreamState.RUNNING)]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WorkstreamManager — thread safety
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestManagerThreadSafety:
|
|
def test_concurrent_create_respects_max(self):
|
|
"""Multiple threads creating workstreams should not exceed max."""
|
|
mgr = WorkstreamManager(_fake_factory, max_workstreams=5)
|
|
errors = []
|
|
created = []
|
|
|
|
def do_create():
|
|
try:
|
|
ws = mgr.create(ui_factory=FakeUI)
|
|
# Mark as non-idle immediately so auto-eviction cannot reclaim it
|
|
mgr.set_state(ws.id, WorkstreamState.RUNNING)
|
|
created.append(ws.id)
|
|
except RuntimeError:
|
|
errors.append(True)
|
|
|
|
threads = [threading.Thread(target=do_create) for _ in range(10)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join()
|
|
|
|
# All threads resolved (created or rejected)
|
|
assert len(created) + len(errors) == 10
|
|
# Never exceeded capacity
|
|
assert mgr.count <= 5
|
|
|
|
def test_concurrent_switch(self):
|
|
"""Concurrent switches should not corrupt state."""
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
ids = []
|
|
for _ in range(5):
|
|
ws = mgr.create(ui_factory=FakeUI)
|
|
ids.append(ws.id)
|
|
|
|
def do_switch(wid):
|
|
for _ in range(20):
|
|
mgr.switch(wid)
|
|
|
|
threads = [threading.Thread(target=do_switch, args=(wid,)) for wid in ids]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join()
|
|
|
|
# active_id should be one of the valid ids
|
|
assert mgr.active_id in ids
|
|
|
|
def test_concurrent_close_and_list(self):
|
|
"""close() and list_all() running concurrently should not crash."""
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
# Keep one alive to prevent closing the last
|
|
anchor = mgr.create(ui_factory=FakeUI)
|
|
targets = []
|
|
for _ in range(5):
|
|
ws = mgr.create(ui_factory=FakeUI)
|
|
targets.append(ws.id)
|
|
|
|
def do_close():
|
|
for wid in targets:
|
|
mgr.close(wid)
|
|
|
|
def do_list():
|
|
for _ in range(50):
|
|
mgr.list_all()
|
|
|
|
t1 = threading.Thread(target=do_close)
|
|
t2 = threading.Thread(target=do_list)
|
|
t1.start()
|
|
t2.start()
|
|
t1.join()
|
|
t2.join()
|
|
|
|
assert mgr.count == 1
|
|
assert mgr.get(anchor.id) is not None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WorkstreamTerminalUI
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestWorkstreamTerminalUI:
|
|
def test_foreground_detection(self):
|
|
from turnstone.cli import WorkstreamTerminalUI
|
|
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
ws = mgr.create(ui_factory=lambda wid: WorkstreamTerminalUI(wid, mgr))
|
|
assert ws.ui.is_foreground is True
|
|
|
|
ws2 = mgr.create(ui_factory=lambda wid: WorkstreamTerminalUI(wid, mgr))
|
|
# ws1 is still active, so ws2 is not foreground
|
|
assert ws2.ui.is_foreground is False
|
|
|
|
def test_state_change_updates_manager(self):
|
|
from turnstone.cli import WorkstreamTerminalUI
|
|
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
ws = mgr.create(ui_factory=lambda wid: WorkstreamTerminalUI(wid, mgr))
|
|
|
|
ws.ui.on_state_change("thinking")
|
|
assert ws.state == WorkstreamState.THINKING
|
|
|
|
ws.ui.on_state_change("idle")
|
|
assert ws.state == WorkstreamState.IDLE
|
|
|
|
def test_invalid_state_change_ignored(self):
|
|
from turnstone.cli import WorkstreamTerminalUI
|
|
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
ws = mgr.create(ui_factory=lambda wid: WorkstreamTerminalUI(wid, mgr))
|
|
ws.ui.on_state_change("not_a_real_state") # should not raise
|
|
assert ws.state == WorkstreamState.IDLE # unchanged
|
|
|
|
def _make_background_ws(self):
|
|
"""Create a manager with two workstreams; switch to the second so the first is background."""
|
|
from turnstone.cli import WorkstreamTerminalUI
|
|
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
bg = mgr.create(ui_factory=lambda wid: WorkstreamTerminalUI(wid, mgr))
|
|
fg = mgr.create(ui_factory=lambda wid: WorkstreamTerminalUI(wid, mgr))
|
|
mgr.switch(fg.id)
|
|
bg.ui.set_foreground(False)
|
|
fg.ui.set_foreground(True)
|
|
return mgr, bg, fg
|
|
|
|
def test_background_buffers_content(self):
|
|
mgr, bg, fg = self._make_background_ws()
|
|
assert bg.ui.is_foreground is False
|
|
|
|
bg.ui.on_content_token("hello ")
|
|
bg.ui.on_content_token("world")
|
|
bg.ui.on_stream_end()
|
|
|
|
assert len(bg.ui._output_buffer) == 3
|
|
assert bg.ui._output_buffer[0] == ("content", "hello ")
|
|
assert bg.ui._output_buffer[1] == ("content", "world")
|
|
assert bg.ui._output_buffer[2] == ("stream_end", "")
|
|
|
|
def test_flush_buffer_clears(self):
|
|
mgr, bg, fg = self._make_background_ws()
|
|
|
|
bg.ui.on_content_token("test")
|
|
bg.ui.on_stream_end()
|
|
assert len(bg.ui._output_buffer) == 2
|
|
|
|
mgr.switch(bg.id)
|
|
bg.ui.set_foreground(True)
|
|
bg.ui.flush_buffer()
|
|
assert len(bg.ui._output_buffer) == 0
|
|
|
|
def test_background_buffers_info_and_error(self):
|
|
mgr, bg, fg = self._make_background_ws()
|
|
|
|
bg.ui.on_info("info msg")
|
|
bg.ui.on_error("error msg")
|
|
|
|
assert ("info", "info msg") in bg.ui._output_buffer
|
|
assert ("error", "error msg") in bg.ui._output_buffer
|
|
|
|
def test_fg_event_blocks_approval_in_background(self):
|
|
"""approve_tools should block until foregrounded."""
|
|
mgr, bg, fg = self._make_background_ws()
|
|
bg.ui.auto_approve = True # so we don't need actual input()
|
|
|
|
result = [None]
|
|
|
|
def call_approve():
|
|
result[0] = bg.ui.approve_tools(
|
|
[{"needs_approval": True, "header": "test", "func_name": "bash"}]
|
|
)
|
|
|
|
t = threading.Thread(target=call_approve)
|
|
t.start()
|
|
time.sleep(0.1)
|
|
assert t.is_alive(), "approve_tools should be blocking"
|
|
|
|
# Bring to foreground — should unblock
|
|
mgr.switch(bg.id)
|
|
bg.ui.set_foreground(True)
|
|
t.join(timeout=2)
|
|
assert not t.is_alive()
|
|
assert result[0] == (True, None) # auto-approved
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WebUI workstream support
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestWebUI:
|
|
def test_ws_id_assigned(self):
|
|
from turnstone.server import WebUI
|
|
|
|
ui = WebUI(ws_id="test-123")
|
|
assert ui.ws_id == "test-123"
|
|
|
|
def test_on_state_change_broadcasts(self):
|
|
"""on_state_change should put an event on the global queue."""
|
|
import queue
|
|
|
|
from turnstone.server import WebUI
|
|
|
|
gq = queue.Queue()
|
|
old = WebUI._global_queue
|
|
WebUI._global_queue = gq
|
|
try:
|
|
ui = WebUI(ws_id="abc")
|
|
ui.on_state_change("thinking")
|
|
|
|
event = gq.get_nowait()
|
|
assert event["type"] == "ws_state"
|
|
assert event["ws_id"] == "abc"
|
|
assert event["state"] == "thinking"
|
|
finally:
|
|
WebUI._global_queue = old
|
|
|
|
def test_on_state_change_no_global_queue(self):
|
|
"""on_state_change should not crash if no global queue is set."""
|
|
from turnstone.server import WebUI
|
|
|
|
old = WebUI._global_queue
|
|
WebUI._global_queue = None
|
|
try:
|
|
ui = WebUI(ws_id="xyz")
|
|
ui.on_state_change("running") # should not raise
|
|
finally:
|
|
WebUI._global_queue = old
|
|
|
|
def test_resolve_approval(self):
|
|
from turnstone.server import WebUI
|
|
|
|
ui = WebUI(ws_id="test")
|
|
ui._approval_event.clear()
|
|
|
|
# Resolve in a thread
|
|
def resolve():
|
|
time.sleep(0.05)
|
|
ui.resolve_approval(True, "looks good")
|
|
|
|
t = threading.Thread(target=resolve)
|
|
t.start()
|
|
|
|
ui._approval_event.wait(timeout=2)
|
|
assert ui._approval_result == (True, "looks good")
|
|
t.join()
|
|
|
|
def test_resolve_approval_emits_event(self):
|
|
"""resolve_approval should enqueue an approval_resolved SSE event."""
|
|
from turnstone.server import WebUI
|
|
|
|
ui = WebUI(ws_id="test-emit")
|
|
listener = ui._register_listener()
|
|
|
|
# Drain any init events
|
|
while not listener.empty():
|
|
listener.get_nowait()
|
|
|
|
ui.resolve_approval(False, "Approval timed out")
|
|
|
|
# Collect events from the listener
|
|
events = []
|
|
while not listener.empty():
|
|
events.append(listener.get_nowait())
|
|
|
|
ui._unregister_listener(listener)
|
|
|
|
resolved = [e for e in events if e.get("type") == "approval_resolved"]
|
|
assert len(resolved) == 1
|
|
assert resolved[0]["approved"] is False
|
|
assert resolved[0]["feedback"] == "Approval timed out"
|
|
|
|
def test_resolve_plan(self):
|
|
from turnstone.server import WebUI
|
|
|
|
ui = WebUI(ws_id="test")
|
|
ui._plan_event.clear()
|
|
|
|
def resolve():
|
|
time.sleep(0.05)
|
|
ui.resolve_plan("approved")
|
|
|
|
t = threading.Thread(target=resolve)
|
|
t.start()
|
|
|
|
ui._plan_event.wait(timeout=2)
|
|
assert ui._plan_result == "approved"
|
|
t.join()
|
|
|
|
def test_pending_plan_review_stored_and_replayed(self):
|
|
"""Plan review state is stored for SSE reconnection replay."""
|
|
from turnstone.server import WebUI
|
|
|
|
ui = WebUI(ws_id="test")
|
|
assert ui._pending_plan_review is None
|
|
|
|
# Simulate on_plan_review in a background thread (it blocks)
|
|
def review():
|
|
ui.on_plan_review("Here is the plan")
|
|
|
|
t = threading.Thread(target=review)
|
|
t.start()
|
|
time.sleep(0.1)
|
|
|
|
# While blocking, pending state should be set
|
|
assert ui._pending_plan_review is not None
|
|
assert ui._pending_plan_review["type"] == "plan_review"
|
|
assert ui._pending_plan_review["content"] == "Here is the plan"
|
|
|
|
# Resolve — pending state should be cleared
|
|
ui.resolve_plan("looks good")
|
|
t.join(timeout=2)
|
|
assert ui._pending_plan_review is None
|
|
assert ui._plan_result == "looks good"
|
|
|
|
def test_pending_plan_review_cleared_on_resolve_before_wait_returns(self):
|
|
"""resolve_plan clears pending state immediately, not just after wait."""
|
|
from turnstone.server import WebUI
|
|
|
|
ui = WebUI(ws_id="test")
|
|
ui._pending_plan_review = {"type": "plan_review", "content": "test"}
|
|
ui.resolve_plan("ok")
|
|
assert ui._pending_plan_review is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WebUI SSE fan-out
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestWebUIFanOut:
|
|
"""Verify per-client SSE fan-out on WebUI._enqueue / _register_listener."""
|
|
|
|
def test_enqueue_no_listeners(self):
|
|
"""Events silently dropped when no listeners are registered."""
|
|
from turnstone.server import WebUI
|
|
|
|
ui = WebUI(ws_id="test")
|
|
ui._enqueue({"type": "content", "text": "hello"}) # should not raise
|
|
|
|
def test_enqueue_single_listener(self):
|
|
"""Single listener receives the event with ws_id stamped."""
|
|
from turnstone.server import WebUI
|
|
|
|
ui = WebUI(ws_id="test")
|
|
q = ui._register_listener()
|
|
ui._enqueue({"type": "content", "text": "hello"})
|
|
assert q.get_nowait() == {"type": "content", "text": "hello", "ws_id": "test"}
|
|
|
|
def test_enqueue_does_not_mutate_input(self):
|
|
"""_enqueue must not mutate the caller's dict."""
|
|
from turnstone.server import WebUI
|
|
|
|
ui = WebUI(ws_id="test")
|
|
ui._register_listener()
|
|
original = {"type": "content", "text": "hello"}
|
|
ui._enqueue(original)
|
|
assert "ws_id" not in original
|
|
|
|
def test_enqueue_multiple_listeners(self):
|
|
"""All registered listeners receive an identical copy."""
|
|
from turnstone.server import WebUI
|
|
|
|
ui = WebUI(ws_id="test")
|
|
q1 = ui._register_listener()
|
|
q2 = ui._register_listener()
|
|
q3 = ui._register_listener()
|
|
|
|
ui._enqueue({"type": "content", "text": "world"})
|
|
|
|
expected = {"type": "content", "text": "world", "ws_id": "test"}
|
|
assert q1.get_nowait() == expected
|
|
assert q2.get_nowait() == expected
|
|
assert q3.get_nowait() == expected
|
|
|
|
def test_unregister_stops_delivery(self):
|
|
"""After unregister, the queue receives no further events."""
|
|
import queue as queue_mod
|
|
|
|
from turnstone.server import WebUI
|
|
|
|
ui = WebUI(ws_id="test")
|
|
q = ui._register_listener()
|
|
ui._unregister_listener(q)
|
|
ui._enqueue({"type": "content", "text": "gone"})
|
|
|
|
with pytest.raises(queue_mod.Empty):
|
|
q.get_nowait()
|
|
|
|
def test_slow_consumer_does_not_block(self):
|
|
"""A full queue doesn't block the producer or starve other listeners."""
|
|
from turnstone.server import WebUI
|
|
|
|
ui = WebUI(ws_id="test")
|
|
slow = ui._register_listener()
|
|
fast = ui._register_listener()
|
|
|
|
# Fill only the slow consumer's queue directly to capacity
|
|
for i in range(500):
|
|
slow.put_nowait({"type": "content", "text": f"fill-{i}"})
|
|
|
|
assert slow.qsize() == 500
|
|
assert fast.qsize() == 0
|
|
|
|
# Enqueue via fan-out — slow drops (full), fast receives
|
|
ui._enqueue({"type": "content", "text": "overflow"})
|
|
assert slow.qsize() == 500 # still full, overflow dropped
|
|
assert fast.qsize() == 1
|
|
assert fast.get_nowait() == {"type": "content", "text": "overflow", "ws_id": "test"}
|
|
|
|
def test_unregister_idempotent(self):
|
|
"""Double unregister does not raise."""
|
|
|
|
from turnstone.server import WebUI
|
|
|
|
ui = WebUI(ws_id="test")
|
|
q = ui._register_listener()
|
|
ui._unregister_listener(q)
|
|
ui._unregister_listener(q) # should not raise
|
|
|
|
def test_concurrent_enqueue_and_register(self):
|
|
"""Concurrent register/unregister and enqueue should not crash."""
|
|
from turnstone.server import WebUI
|
|
|
|
ui = WebUI(ws_id="test")
|
|
stop = threading.Event()
|
|
|
|
def register_loop():
|
|
while not stop.is_set():
|
|
q = ui._register_listener()
|
|
ui._unregister_listener(q)
|
|
|
|
def enqueue_loop():
|
|
for i in range(500):
|
|
ui._enqueue({"type": "content", "text": f"tok-{i}"})
|
|
|
|
t1 = threading.Thread(target=register_loop)
|
|
t2 = threading.Thread(target=enqueue_loop)
|
|
t1.start()
|
|
t2.start()
|
|
t2.join()
|
|
stop.set()
|
|
t1.join()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Integration: WorkstreamManager + session state transitions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestStateTransitions:
|
|
def test_full_lifecycle(self):
|
|
"""Verify the expected state transition sequence."""
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
ws = mgr.create(ui_factory=FakeUI)
|
|
|
|
# Simulate the state transitions that ChatSession.send() would emit
|
|
mgr.set_state(ws.id, WorkstreamState.THINKING)
|
|
assert ws.state == WorkstreamState.THINKING
|
|
|
|
mgr.set_state(ws.id, WorkstreamState.RUNNING)
|
|
assert ws.state == WorkstreamState.RUNNING
|
|
|
|
mgr.set_state(ws.id, WorkstreamState.ATTENTION)
|
|
assert ws.state == WorkstreamState.ATTENTION
|
|
|
|
mgr.set_state(ws.id, WorkstreamState.RUNNING)
|
|
assert ws.state == WorkstreamState.RUNNING
|
|
|
|
mgr.set_state(ws.id, WorkstreamState.IDLE)
|
|
assert ws.state == WorkstreamState.IDLE
|
|
|
|
def test_error_recovery(self):
|
|
"""After an error, sending again should transition back to thinking."""
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
ws = mgr.create(ui_factory=FakeUI)
|
|
|
|
mgr.set_state(ws.id, WorkstreamState.ERROR, "API failed")
|
|
assert ws.state == WorkstreamState.ERROR
|
|
assert ws.error_message == "API failed"
|
|
|
|
mgr.set_state(ws.id, WorkstreamState.THINKING)
|
|
assert ws.state == WorkstreamState.THINKING
|
|
assert ws.error_message == ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Design polish: thread-safe buffer, approval context, NO_COLOR
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBufferThreadSafety:
|
|
"""Verify that _buffer() uses the lock and flush_buffer copies under lock."""
|
|
|
|
def test_concurrent_buffer_and_flush(self):
|
|
"""Simultaneous buffering and flushing should not lose or corrupt events."""
|
|
from turnstone.cli import WorkstreamTerminalUI
|
|
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
bg = mgr.create(ui_factory=lambda wid: WorkstreamTerminalUI(wid, mgr))
|
|
fg = mgr.create(ui_factory=lambda wid: WorkstreamTerminalUI(wid, mgr))
|
|
mgr.switch(fg.id)
|
|
bg.ui.set_foreground(False)
|
|
|
|
n_events = 200
|
|
done = threading.Event()
|
|
|
|
def do_buffer():
|
|
for i in range(n_events):
|
|
bg.ui._buffer("content", f"token-{i}")
|
|
done.set()
|
|
|
|
t = threading.Thread(target=do_buffer)
|
|
t.start()
|
|
done.wait()
|
|
|
|
# All events should be in the buffer
|
|
with bg.ui._print_lock:
|
|
count = len(bg.ui._output_buffer)
|
|
assert count == n_events
|
|
t.join()
|
|
|
|
|
|
class TestApprovalContextMessage:
|
|
"""Verify approval in background buffers a context message."""
|
|
|
|
def test_approval_buffers_tool_names(self):
|
|
from turnstone.cli import WorkstreamTerminalUI
|
|
|
|
mgr = WorkstreamManager(_fake_factory)
|
|
bg = mgr.create(ui_factory=lambda wid: WorkstreamTerminalUI(wid, mgr))
|
|
fg = mgr.create(ui_factory=lambda wid: WorkstreamTerminalUI(wid, mgr))
|
|
mgr.switch(fg.id)
|
|
bg.ui.set_foreground(False)
|
|
bg.ui.auto_approve = True
|
|
|
|
result = [None]
|
|
|
|
def call_approve():
|
|
result[0] = bg.ui.approve_tools(
|
|
[
|
|
{
|
|
"needs_approval": True,
|
|
"header": "test",
|
|
"func_name": "bash",
|
|
"approval_label": "bash: ls",
|
|
},
|
|
]
|
|
)
|
|
|
|
t = threading.Thread(target=call_approve)
|
|
t.start()
|
|
time.sleep(0.1)
|
|
|
|
# Should have a waiting-for-approval message in the buffer
|
|
with bg.ui._print_lock:
|
|
info_msgs = [text for ev, text in bg.ui._output_buffer if ev == "info"]
|
|
assert any("bash: ls" in msg for msg in info_msgs)
|
|
|
|
# Unblock
|
|
mgr.switch(bg.id)
|
|
bg.ui.set_foreground(True)
|
|
t.join(timeout=2)
|
|
assert result[0] == (True, None)
|
|
|
|
|
|
class TestNoColor:
|
|
"""Verify NO_COLOR support in colors module."""
|
|
|
|
def test_no_color_env_disables_ansi(self):
|
|
import importlib
|
|
import os
|
|
|
|
import turnstone.ui.colors as colors_mod
|
|
|
|
old_env = os.environ.get("NO_COLOR")
|
|
try:
|
|
os.environ["NO_COLOR"] = "1"
|
|
importlib.reload(colors_mod)
|
|
assert colors_mod.RESET == ""
|
|
assert colors_mod.BOLD == ""
|
|
assert colors_mod.RED == ""
|
|
assert colors_mod.red("test") == "test"
|
|
finally:
|
|
if old_env is None:
|
|
os.environ.pop("NO_COLOR", None)
|
|
else:
|
|
os.environ["NO_COLOR"] = old_env
|
|
importlib.reload(colors_mod)
|