Files
turnstone/tests/test_workstream.py
T
Patrick Buckley 205e7818f8 Fix/codeql quality findings (#299)
* 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
2026-04-04 17:53:20 -07:00

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)