Files
2026-07-13 13:12:33 +08:00

904 lines
28 KiB
Python

from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
from typing import Any
import pytest
from opensquilla.cli.tui.backend.contracts import (
TuiInputKind,
TuiRuntimeConfig,
TuiRuntimeHooks,
)
from opensquilla.cli.tui.backend.runtime import run_tui_runtime
from opensquilla.cli.tui.backend.state import TuiRuntimeState
from opensquilla.engine.agent_injection import PendingInputProvider
class _FakeSurface:
def __init__(
self,
inputs: asyncio.Queue[str | None],
*,
on_next_line: Callable[[], None] | None = None,
) -> None:
self._inputs = inputs
self._on_next_line = on_next_line
self.next_line_calls = 0
self.cancel_callbacks: list[Any] = []
self.shutdown_callbacks: list[Any] = []
self.writes: list[str] = []
self.eof_count = 0
self.redraw_count = 0
async def next_line(self) -> str | None:
self.next_line_calls += 1
if self._on_next_line is not None:
self._on_next_line()
return await self._inputs.get()
def set_cancel_callback(self, cb) -> None: # noqa: ANN001
self.cancel_callbacks.append(cb)
def set_shutdown_callback(self, cb) -> None: # noqa: ANN001
self.shutdown_callbacks.append(cb)
def emit_eof(self) -> None:
self.eof_count += 1
self._inputs.put_nowait(None)
async def write_through(self, payload: str) -> None:
self.writes.append(payload)
@property
def redraw_callback(self) -> Callable[[], None]:
return self._invalidate
def _invalidate(self) -> None:
self.redraw_count += 1
def _surface_factory(surface: _FakeSurface):
@asynccontextmanager
async def _factory() -> AsyncIterator[_FakeSurface]:
yield surface
return _factory
async def _noop_echo(surface: Any, text: str) -> None:
await surface.write_through(f"echo:{text}")
async def _queued_echo(surface: Any) -> None:
await surface.write_through("queued")
async def _wait_until(predicate: Callable[[], bool], *, timeout: float = 2.0) -> None:
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
while not predicate():
if loop.time() >= deadline:
raise AssertionError("timed out waiting for runtime condition")
await asyncio.sleep(0)
def _runtime_config(**kwargs: Any) -> TuiRuntimeConfig:
return TuiRuntimeConfig(task_name="chat-turn-test", **kwargs)
def _runtime_hooks(**kwargs: Any) -> TuiRuntimeHooks:
return TuiRuntimeHooks(
on_user_input_echo=_noop_echo,
on_queued_turn_start=_queued_echo,
**kwargs,
)
@pytest.mark.asyncio
async def test_runtime_ignores_blank_input_lines() -> None:
"""A blank Enter is never a message: no dispatch, no echo, no queue entry.
Surfaces guard this too, but the backend is the defense shared by every
frontend — a phantom empty prompt card queued behind a running turn was
the visible failure.
"""
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
state = TuiRuntimeState()
executed: list[str] = []
echoed: list[str] = []
async def _dispatch(user_input: str) -> bool:
executed.append(user_input)
return True
async def _echo(_surface: Any, text: str) -> None:
echoed.append(text)
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(state=state),
hooks=TuiRuntimeHooks(
on_user_input_echo=_echo,
on_queued_turn_start=_queued_echo,
),
)
)
inputs.put_nowait("")
inputs.put_nowait(" ")
inputs.put_nowait("\t")
inputs.put_nowait("real message")
await _wait_until(lambda: executed == ["real message"])
inputs.put_nowait(None)
await task
assert executed == ["real message"]
assert echoed == ["real message"]
assert state.pending_items == ()
def test_runtime_state_drains_pending_inputs_for_agent_injection() -> None:
state = TuiRuntimeState()
state.enqueue("second")
state.enqueue("third")
assert isinstance(state, PendingInputProvider)
assert state.drain_pending() == ["second", "third"]
assert state.pending_items == ()
@pytest.mark.asyncio
async def test_runtime_allows_active_turn_to_drain_mid_turn_input() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
state = TuiRuntimeState()
executed: list[str] = []
drained: list[str] = []
first_started = asyncio.Event()
drain_completed = asyncio.Event()
async def _dispatch(user_input: str) -> bool:
executed.append(user_input)
if user_input == "first":
first_started.set()
await _wait_until(lambda: state.pending_items == ("second",))
drained.extend(state.drain_pending())
drain_completed.set()
return True
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(state=state),
hooks=_runtime_hooks(),
)
)
await inputs.put("first")
await asyncio.wait_for(first_started.wait(), timeout=2.0)
await inputs.put("second")
await asyncio.wait_for(drain_completed.wait(), timeout=2.0)
await inputs.put(None)
await asyncio.wait_for(task, timeout=2.0)
assert drained == ["second"]
assert executed == ["first"]
assert state.pending_items == ()
assert "queued" not in surface.writes
@pytest.mark.asyncio
async def test_runtime_dispatches_single_input_against_fake_surface() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
executed: list[str] = []
async def _dispatch(user_input: str) -> bool:
executed.append(user_input)
return True
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(),
hooks=_runtime_hooks(),
)
)
await inputs.put("hello")
await inputs.put(None)
await asyncio.wait_for(task, timeout=2.0)
assert executed == ["hello"]
assert surface.writes == ["echo:hello"]
assert surface.cancel_callbacks[-1] is None
assert surface.shutdown_callbacks[-1] is None
@pytest.mark.asyncio
async def test_runtime_queues_pending_input_and_promotes_fifo() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
executed: list[str] = []
first_started = asyncio.Event()
finish_first = asyncio.Event()
async def _dispatch(user_input: str) -> bool:
executed.append(user_input)
if user_input == "first":
first_started.set()
await finish_first.wait()
return True
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(),
hooks=_runtime_hooks(),
)
)
await inputs.put("first")
await asyncio.wait_for(first_started.wait(), timeout=2.0)
await inputs.put("second")
await inputs.put("third")
for _ in range(20):
await asyncio.sleep(0)
finish_first.set()
await inputs.put(None)
await asyncio.wait_for(task, timeout=2.0)
assert executed == ["first", "second", "third"]
assert surface.writes.count("queued") == 2
@pytest.mark.asyncio
async def test_runtime_can_defer_next_input_until_turn_finishes() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
executed: list[str] = []
first_started = asyncio.Event()
finish_first = asyncio.Event()
async def _dispatch(user_input: str) -> bool:
executed.append(user_input)
first_started.set()
await finish_first.wait()
return True
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(concurrent_input_during_turn=False),
hooks=_runtime_hooks(),
)
)
await inputs.put("first")
await asyncio.wait_for(first_started.wait(), timeout=2.0)
for _ in range(20):
await asyncio.sleep(0)
assert surface.next_line_calls == 1
finish_first.set()
await inputs.put(None)
await asyncio.wait_for(task, timeout=2.0)
assert executed == ["first"]
assert surface.next_line_calls == 2
@pytest.mark.asyncio
async def test_runtime_can_keep_bottom_input_live_during_turn() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
second_input_started = asyncio.Event()
surface = _FakeSurface(
inputs,
on_next_line=lambda: second_input_started.set() if surface.next_line_calls >= 2 else None,
)
first_started = asyncio.Event()
finish_first = asyncio.Event()
executed: list[str] = []
async def _dispatch(user_input: str) -> bool:
executed.append(user_input)
first_started.set()
await finish_first.wait()
return True
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(concurrent_input_during_turn=True),
hooks=_runtime_hooks(),
)
)
await inputs.put("first")
await asyncio.wait_for(first_started.wait(), timeout=2.0)
await asyncio.wait_for(second_input_started.wait(), timeout=2.0)
assert surface.next_line_calls >= 2
await inputs.put(None)
finish_first.set()
await asyncio.wait_for(task, timeout=2.0)
assert executed == ["first"]
@pytest.mark.asyncio
async def test_runtime_starts_next_input_before_dispatch_when_concurrent() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
events: list[str] = []
def _record_next_line() -> None:
events.append(f"input-{surface.next_line_calls}")
surface = _FakeSurface(inputs, on_next_line=_record_next_line)
dispatch_started = asyncio.Event()
finish_dispatch = asyncio.Event()
async def _dispatch(user_input: str) -> bool:
events.append(f"dispatch-{user_input}")
dispatch_started.set()
await finish_dispatch.wait()
return True
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(concurrent_input_during_turn=True),
hooks=_runtime_hooks(),
)
)
await inputs.put("first")
await asyncio.wait_for(dispatch_started.wait(), timeout=2.0)
assert events.index("input-2") < events.index("dispatch-first")
await inputs.put(None)
finish_dispatch.set()
await asyncio.wait_for(task, timeout=2.0)
@pytest.mark.asyncio
async def test_runtime_destructive_slash_cancels_active_turn_and_purges_queue() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
executed: list[str] = []
first_started = asyncio.Event()
first_cancelled = asyncio.Event()
clear_completed = asyncio.Event()
cancel_calls: list[str] = []
clear_cancel_snapshots: list[list[str]] = []
async def _dispatch(user_input: str) -> bool:
if user_input == "first":
executed.append("first-start")
first_started.set()
try:
await asyncio.sleep(5)
except asyncio.CancelledError:
first_cancelled.set()
raise
executed.append("first-end")
return True
executed.append(user_input)
if user_input == "/clear":
clear_cancel_snapshots.append(list(cancel_calls))
clear_completed.set()
return True
async def _cancel_active_turn() -> None:
await asyncio.sleep(0)
cancel_calls.append("cancel")
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(
classify_input=lambda text: (
TuiInputKind.DESTRUCTIVE if text == "/clear" else TuiInputKind.NORMAL
)
),
hooks=_runtime_hooks(on_cancel_active_turn=_cancel_active_turn),
)
)
await inputs.put("first")
await asyncio.wait_for(first_started.wait(), timeout=2.0)
await inputs.put("second")
await inputs.put("third")
for _ in range(20):
await asyncio.sleep(0)
await inputs.put("/clear")
await asyncio.wait_for(first_cancelled.wait(), timeout=2.0)
await asyncio.wait_for(clear_completed.wait(), timeout=2.0)
await inputs.put(None)
await asyncio.wait_for(task, timeout=2.0)
assert executed == ["first-start", "/clear"]
assert cancel_calls == ["cancel"]
assert clear_cancel_snapshots == [["cancel"]]
@pytest.mark.asyncio
async def test_runtime_exit_drains_pending_work_before_dispatching_exit() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
executed: list[str] = []
first_started = asyncio.Event()
finish_first = asyncio.Event()
async def _dispatch(user_input: str) -> bool:
if user_input == "first":
executed.append("first-start")
first_started.set()
await finish_first.wait()
executed.append("first-end")
return True
executed.append(user_input)
return user_input != "/exit"
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(
classify_input=lambda text: (
TuiInputKind.EXIT if text == "/exit" else TuiInputKind.NORMAL
)
),
hooks=_runtime_hooks(),
)
)
await inputs.put("first")
await asyncio.wait_for(first_started.wait(), timeout=2.0)
await inputs.put("second")
await inputs.put("third")
for _ in range(20):
await asyncio.sleep(0)
await inputs.put("/exit")
for _ in range(20):
await asyncio.sleep(0)
finish_first.set()
await asyncio.wait_for(task, timeout=2.0)
assert executed == ["first-start", "first-end", "second", "third", "/exit"]
@pytest.mark.asyncio
async def test_runtime_cancel_invokes_adapter_cancel_hook() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
dispatch_started = asyncio.Event()
dispatch_cancelled = asyncio.Event()
cancel_calls: list[str] = []
async def _dispatch(user_input: str) -> bool:
dispatch_started.set()
try:
await asyncio.sleep(5)
except asyncio.CancelledError:
dispatch_cancelled.set()
raise
return True
async def _cancel_active_turn() -> None:
cancel_calls.append("cancel")
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(),
hooks=_runtime_hooks(on_cancel_active_turn=_cancel_active_turn),
)
)
await inputs.put("hello")
await asyncio.wait_for(dispatch_started.wait(), timeout=2.0)
active_cb = next(cb for cb in reversed(surface.cancel_callbacks) if cb is not None)
active_cb()
await inputs.put(None)
await asyncio.wait_for(task, timeout=2.0)
assert cancel_calls == ["cancel"]
assert dispatch_cancelled.is_set() is True
@pytest.mark.asyncio
async def test_runtime_cancel_drops_pending_inputs_from_active_turn() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
state = TuiRuntimeState()
executed: list[str] = []
first_started = asyncio.Event()
async def _dispatch(user_input: str) -> bool:
if user_input == "first":
executed.append("first-start")
first_started.set()
try:
await asyncio.sleep(5)
except asyncio.CancelledError:
executed.append("first-cancelled")
raise
executed.append(user_input)
return True
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(state=state),
hooks=_runtime_hooks(),
)
)
await inputs.put("first")
await asyncio.wait_for(first_started.wait(), timeout=2.0)
await inputs.put("second")
await inputs.put("third")
await _wait_until(lambda: state.pending_items == ("second", "third"))
active_cb = next(cb for cb in reversed(surface.cancel_callbacks) if cb is not None)
active_cb()
await inputs.put(None)
await asyncio.wait_for(task, timeout=2.0)
assert executed == ["first-start", "first-cancelled"]
assert state.pending_items == ()
assert "queued" not in surface.writes
@pytest.mark.asyncio
async def test_runtime_cancel_notifies_when_dropping_queued_inputs() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
state = TuiRuntimeState()
notices: list[str] = []
first_started = asyncio.Event()
async def _dispatch(user_input: str) -> bool:
if user_input == "first":
first_started.set()
await asyncio.sleep(5)
return True
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(state=state),
hooks=_runtime_hooks(notice=notices.append),
)
)
await inputs.put("first")
await asyncio.wait_for(first_started.wait(), timeout=2.0)
await inputs.put("second")
await inputs.put("third")
await _wait_until(lambda: state.pending_items == ("second", "third"))
active_cb = next(cb for cb in reversed(surface.cancel_callbacks) if cb is not None)
active_cb()
await inputs.put(None)
await asyncio.wait_for(task, timeout=2.0)
# The two typed-ahead messages were dropped — the user is told, not left guessing.
assert any("Discarded 2 queued messages" in notice for notice in notices)
@pytest.mark.asyncio
async def test_runtime_cancel_without_queue_emits_no_discard_notice() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
state = TuiRuntimeState()
notices: list[str] = []
first_started = asyncio.Event()
async def _dispatch(user_input: str) -> bool:
if user_input == "first":
first_started.set()
await asyncio.sleep(5)
return True
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(state=state),
hooks=_runtime_hooks(notice=notices.append),
)
)
await inputs.put("first")
await asyncio.wait_for(first_started.wait(), timeout=2.0)
active_cb = next(cb for cb in reversed(surface.cancel_callbacks) if cb is not None)
active_cb() # cancel with an empty queue
await inputs.put(None)
await asyncio.wait_for(task, timeout=2.0)
assert not any("Discarded" in notice for notice in notices)
@pytest.mark.asyncio
async def test_runtime_notifies_when_input_is_queued_mid_turn() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
state = TuiRuntimeState()
notices: list[str] = []
first_started = asyncio.Event()
async def _dispatch(user_input: str) -> bool:
if user_input == "first":
first_started.set()
await asyncio.sleep(5)
return True
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(state=state),
hooks=_runtime_hooks(notice=notices.append),
)
)
await inputs.put("first")
await asyncio.wait_for(first_started.wait(), timeout=2.0)
await inputs.put("second")
await _wait_until(lambda: state.pending_items == ("second",))
active_cb = next(cb for cb in reversed(surface.cancel_callbacks) if cb is not None)
active_cb()
await inputs.put(None)
await asyncio.wait_for(task, timeout=2.0)
# Typing mid-turn must surface a clear "queued" signal — the message was not
# silently dropped, nor did it start a turn.
assert any("Queued (#1) behind the running turn" in notice for notice in notices)
@pytest.mark.asyncio
async def test_runtime_full_queue_rejects_message_without_echoing_it() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
state = TuiRuntimeState()
echoed: list[str] = []
notices: list[str] = []
first_started = asyncio.Event()
async def _record_echo(_surface: Any, text: str) -> None:
echoed.append(text)
async def _dispatch(user_input: str) -> bool:
if user_input == "first":
first_started.set()
await asyncio.sleep(5)
return True
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(state=state, queue_max_size=1),
hooks=TuiRuntimeHooks(
on_user_input_echo=_record_echo,
on_queued_turn_start=_queued_echo,
notice=notices.append,
),
)
)
await inputs.put("first")
await asyncio.wait_for(first_started.wait(), timeout=2.0)
await inputs.put("second") # fills the single queue slot
await _wait_until(lambda: state.pending_items == ("second",))
await inputs.put("third") # queue is full -> must be rejected
await _wait_until(lambda: any("Queue full" in notice for notice in notices))
active_cb = next(cb for cb in reversed(surface.cancel_callbacks) if cb is not None)
active_cb()
await inputs.put(None)
await asyncio.wait_for(task, timeout=2.0)
# The rejected message must NOT appear accepted in the transcript; the one that
# fit was echoed normally.
assert "second" in echoed
assert "third" not in echoed
assert state.pending_items == ()
@pytest.mark.asyncio
async def test_runtime_cancel_suppresses_adapter_cancel_hook_errors() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
dispatch_started = asyncio.Event()
dispatch_cancelled = asyncio.Event()
async def _dispatch(user_input: str) -> bool:
dispatch_started.set()
try:
await asyncio.sleep(5)
except asyncio.CancelledError:
dispatch_cancelled.set()
raise
return True
def _cancel_active_turn() -> Awaitable[None]:
raise RuntimeError("abort hook failed")
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(),
hooks=_runtime_hooks(on_cancel_active_turn=_cancel_active_turn),
)
)
await inputs.put("hello")
await asyncio.wait_for(dispatch_started.wait(), timeout=2.0)
active_cb = next(cb for cb in reversed(surface.cancel_callbacks) if cb is not None)
active_cb()
await inputs.put(None)
await asyncio.wait_for(task, timeout=2.0)
assert dispatch_cancelled.is_set() is True
@pytest.mark.asyncio
async def test_runtime_installs_surface_redraw_callback_for_resize() -> None:
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
installed = asyncio.Event()
captured_resize_callbacks: list[Callable[[], None]] = []
def _install_signal_handlers(
*,
loop: asyncio.AbstractEventLoop,
on_resize: Callable[[], None],
is_turn_in_flight: Callable[[], bool],
) -> Callable[[], None]:
del loop, is_turn_in_flight
captured_resize_callbacks.append(on_resize)
installed.set()
return lambda: None
async def _dispatch(user_input: str) -> bool:
raise AssertionError(f"dispatch should not run: {user_input}")
task = asyncio.create_task(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(install_signal_handlers=_install_signal_handlers),
)
)
await asyncio.wait_for(installed.wait(), timeout=2.0)
captured_resize_callbacks[0]()
await inputs.put(None)
await asyncio.wait_for(task, timeout=2.0)
assert surface.redraw_count == 1
class _RaisingSurface:
"""Surface whose ``next_line`` fails, modelling an OpenTUI host read error."""
def __init__(self, error: Exception) -> None:
self._error = error
self.cancel_callbacks: list[Any] = []
self.shutdown_callbacks: list[Any] = []
async def next_line(self) -> str | None:
raise self._error
def set_cancel_callback(self, cb) -> None: # noqa: ANN001
self.cancel_callbacks.append(cb)
def set_shutdown_callback(self, cb) -> None: # noqa: ANN001
self.shutdown_callbacks.append(cb)
def emit_eof(self) -> None:
return None
async def write_through(self, payload: str) -> None:
return None
@property
def redraw_callback(self) -> Callable[[], None]:
return lambda: None
@pytest.mark.asyncio
async def test_runtime_degrades_gracefully_on_surface_read_error() -> None:
surface = _RaisingSurface(RuntimeError("OpenTUI host error: [boom]"))
notices: list[str] = []
async def _dispatch(_user_input: str) -> bool:
raise AssertionError("dispatch should not run when input never arrives")
result = await run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(),
hooks=_runtime_hooks(notice=notices.append),
)
assert isinstance(result, TuiRuntimeState)
assert any("Input surface error" in notice for notice in notices)
# the dynamic error text is markup-escaped so the notice itself cannot crash.
assert any("\\[boom]" in notice for notice in notices)
@pytest.mark.asyncio
async def test_runtime_runs_local_command_inline_without_echo_or_queue() -> None:
# A LOCAL command (e.g. /theme) typed while a turn is streaming must run
# immediately, never echoed as a prompt and never queued behind the turn.
inputs: asyncio.Queue[str | None] = asyncio.Queue()
surface = _FakeSurface(inputs)
dispatched: list[str] = []
echoed: list[str] = []
queued_starts = 0
turn_running = asyncio.Event()
release_turn = asyncio.Event()
async def _dispatch(user_input: str) -> bool:
dispatched.append(user_input)
if user_input == "hi":
turn_running.set()
await release_turn.wait() # hold the turn in flight
return True
async def _echo(_surface: Any, text: str) -> None:
echoed.append(text)
async def _queued_start(_surface: Any) -> None:
nonlocal queued_starts
queued_starts += 1
def _classify(text: str) -> TuiInputKind:
return TuiInputKind.LOCAL if text.startswith("/theme") else TuiInputKind.NORMAL
task = asyncio.ensure_future(
run_tui_runtime(
dispatch=_dispatch,
surface_factory=_surface_factory(surface),
config=_runtime_config(concurrent_input_during_turn=True, classify_input=_classify),
hooks=TuiRuntimeHooks(on_user_input_echo=_echo, on_queued_turn_start=_queued_start),
)
)
await inputs.put("hi")
await turn_running.wait()
await inputs.put("/theme") # LOCAL command while "hi" streams
await _wait_until(lambda: "/theme" in dispatched)
# dispatched immediately, NOT echoed as a prompt, NOT queued, no queued marker
assert echoed == ["hi"] # only the chat message was echoed
assert queued_starts == 0
assert surface.next_line_calls # input was being read concurrently
release_turn.set()
await inputs.put(None)
await asyncio.wait_for(task, timeout=2.0)
assert dispatched == ["hi", "/theme"]