refactor(cli): replace channel module globals with ChannelRuntime (#197)

* refactor(cli): replace channel module globals with ChannelRuntime

Removes _cli_agent / _cli_thread_id from EvoScientist/cli/channel.py
and threads a ChannelRuntime via CommandContext.channel_runtime so
/model and /channel rebind without poking module-level state.

* fix(cli): address coderabbit review

- _auto_start_channel: bind ChannelRuntime only after
  _start_channels_bus_mode succeeds, so a startup failure no longer
  leaves a stale binding pointing at channels that never started.
- _sync_tui_command_completion (TUI) and the Rich CLI command-completion
  paths: rebind the runtime on thread rotation, not just agent swap, so
  /new and /resume keep ChannelRuntime in sync with the running thread
  (matches the serve-mode hook contract).
- test_hook_syncs_channel_runtime: pin ctx.thread_id explicitly so a
  bare MagicMock attribute can't silently mutate runtime.thread_id.
- New regression test covering the rebind-on-thread-rotation contract.
This commit is contained in:
dinos
2026-04-30 19:06:22 +02:00
committed by GitHub
parent 7cfec02416
commit 5c829942d7
12 changed files with 228 additions and 156 deletions
+29 -15
View File
@@ -22,6 +22,7 @@ from typing import Any
from rich.panel import Panel
from rich.text import Text
from ..commands.base import ChannelRuntime
from ..stream.console import console
_channel_logger = logging.getLogger(__name__)
@@ -256,6 +257,7 @@ async def dispatch_channel_slash_command(
handle_session_resume_cb: Callable[..., Awaitable[None]] | None = None,
await_agent_ready: Callable[[], Awaitable[Any]] | None = None,
on_cmd_completed: Callable[..., Awaitable[None]] | None = None,
channel_runtime: ChannelRuntime | None = None,
) -> bool:
"""Dispatch a slash command from a channel message.
@@ -317,6 +319,7 @@ async def dispatch_channel_slash_command(
handle_session_resume_cb=handle_session_resume_cb,
await_agent_ready=await_agent_ready,
on_cmd_completed=on_cmd_completed,
channel_runtime=channel_runtime,
)
except Exception as exc:
# Last-ditch safety: any uncaught exception from inside the
@@ -351,6 +354,7 @@ async def _dispatch_channel_slash_impl(
handle_session_resume_cb: Callable[..., Awaitable[None]] | None,
await_agent_ready: Callable[[], Awaitable[Any]] | None,
on_cmd_completed: Callable[..., Awaitable[None]] | None,
channel_runtime: ChannelRuntime | None,
) -> bool:
"""Inner body of ``dispatch_channel_slash_command``.
@@ -389,6 +393,7 @@ async def _dispatch_channel_slash_impl(
ui=ui,
workspace_dir=workspace_dir,
checkpointer=checkpointer,
channel_runtime=channel_runtime,
)
try:
@@ -695,8 +700,6 @@ def channel_hitl_prompt(
_manager: Any | None = None # ChannelManager
_bus_loop: asyncio.AbstractEventLoop | None = None
_bus_thread: threading.Thread | None = None
_cli_agent: Any = None # shared agent reference (same as CLI)
_cli_thread_id: str | None = None # shared thread_id (same conversation)
def _channels_is_running(channel_type: str | None = None) -> bool:
@@ -714,9 +717,18 @@ def _channels_running_list() -> list[str]:
return _manager.running_channels() if _manager else []
def _channels_stop(channel_type: str | None = None) -> None:
"""Stop channel(s) and clean up module-level state."""
global _manager, _bus_loop, _bus_thread, _cli_agent, _cli_thread_id
def _channels_stop(
channel_type: str | None = None,
*,
runtime: ChannelRuntime | None = None,
) -> None:
"""Stop channel(s) and clean up module-level state.
``runtime`` is the ``ChannelRuntime`` whose binding should be
cleared once the channels are gone — the caller owns it (commands
keep a reference via ``ctx.channel_runtime``).
"""
global _manager, _bus_loop, _bus_thread
if channel_type is None:
# Stop everything
@@ -736,8 +748,8 @@ def _channels_stop(channel_type: str | None = None) -> None:
_manager = None
_bus_loop = None
_bus_thread = None
_cli_agent = None
_cli_thread_id = None
if runtime is not None:
runtime.clear()
return
# Stop a specific channel
@@ -751,9 +763,8 @@ def _channels_stop(channel_type: str | None = None) -> None:
except Exception as e:
_channel_logger.debug(f"Error removing channel {channel_type}: {e}")
if _manager and not _manager.running_channels():
_cli_agent = None
_cli_thread_id = None
if _manager and not _manager.running_channels() and runtime is not None:
runtime.clear()
def _start_channels_bus_mode(
@@ -1071,6 +1082,7 @@ def _auto_start_channel(
config,
*,
send_thinking: bool | None = None,
runtime: ChannelRuntime | None = None,
) -> None:
"""Start channels automatically from config (bus mode).
@@ -1078,21 +1090,23 @@ def _auto_start_channel(
agent: Compiled agent graph.
thread_id: Current thread ID.
config: EvoScientistConfig with channel settings.
runtime: Caller-owned ``ChannelRuntime`` to bind so commands
running over the channels can swap the agent later. ``None``
is accepted for callers that don't yet pass one.
"""
global _cli_agent, _cli_thread_id
if not config.channel_enabled:
return
_cli_agent = agent
_cli_thread_id = thread_id
_start_channels_bus_mode(
config,
agent,
thread_id,
send_thinking=send_thinking,
)
# Bind only after startup succeeds; a failure above must not leave
# a stale runtime binding pointing at channels that never started.
if runtime is not None:
runtime.bind(agent, thread_id)
types = [t.strip() for t in config.channel_enabled.split(",") if t.strip()]
results = [(ct, True, "connected (bus)") for ct in types]
_print_channel_panel(results)
+33 -27
View File
@@ -501,7 +501,10 @@ async def compact_conversation(
_serve_logger = logging.getLogger(__name__)
def _make_serve_start_new_session_cb(agent_holder: dict[str, Any]):
def _make_serve_start_new_session_cb(
agent_holder: dict[str, Any],
channel_runtime: Any | None = None,
):
"""Build the ``start_new_session_cb`` used by serve mode.
``/new`` delegates session rotation entirely to this callback: it
@@ -510,8 +513,8 @@ def _make_serve_start_new_session_cb(agent_holder: dict[str, Any]):
fresh thread id. Without a wired callback the channel user gets
``ChannelCommandUI``'s fallback "restart the channel link" message
and nothing actually rotates. This helper generates a new thread
id, updates the shared holder, and syncs the channel-module global
so subsequent messages land on the new thread.
id, updates the shared holder, and syncs the channel runtime so
subsequent messages land on the new thread.
"""
def _cb() -> None:
@@ -519,25 +522,23 @@ def _make_serve_start_new_session_cb(agent_holder: dict[str, Any]):
new_tid = generate_thread_id()
agent_holder["thread_id"] = new_tid
try:
import EvoScientist.cli.channel as _ch_mod
_ch_mod._cli_thread_id = new_tid
except Exception: # pragma: no cover — defensive
pass
if channel_runtime is not None:
channel_runtime.thread_id = new_tid
console.print(f"[dim][serve] New thread: {new_tid}[/dim]")
return _cb
def _make_serve_cmd_completed_hook(agent_holder: dict[str, Any]):
def _make_serve_cmd_completed_hook(
agent_holder: dict[str, Any],
channel_runtime: Any | None = None,
):
"""Build the ``on_cmd_completed`` hook used by serve mode.
Adopts ``/model`` agent swaps and ``/resume`` thread/workspace
swaps back into ``agent_holder`` so the outer poll loop picks up
the new handles on subsequent messages. Also keeps
``EvoScientist.cli.channel`` globals in sync so other readers
(e.g. the bus) see the new values.
``channel_runtime`` in sync so the bus sees the new values.
For ``/resume`` specifically, surface a user-visible warning via
``ctx.ui``: serve uses ``InMemorySaver`` (not the SQLite
@@ -551,14 +552,10 @@ def _make_serve_cmd_completed_hook(agent_holder: dict[str, Any]):
"""
async def _hook(ctx: Any, original_agent: Any, cmd: Any) -> None:
import EvoScientist.cli.channel as _ch_mod
if ctx.agent is not None and ctx.agent is not original_agent:
agent_holder["agent"] = ctx.agent
try:
_ch_mod._cli_agent = ctx.agent
except Exception: # pragma: no cover — defensive
pass
if channel_runtime is not None:
channel_runtime.agent = ctx.agent
# ``/resume`` mutates ``ctx.thread_id`` directly (its UI callback
# is a no-op in serve mode since there's no REPL to reset). Pick
@@ -572,10 +569,8 @@ def _make_serve_cmd_completed_hook(agent_holder: dict[str, Any]):
thread_changed = bool(new_tid) and new_tid != agent_holder.get("thread_id")
if thread_changed:
agent_holder["thread_id"] = new_tid
try:
_ch_mod._cli_thread_id = new_tid
except Exception: # pragma: no cover — defensive
pass
if channel_runtime is not None:
channel_runtime.thread_id = new_tid
new_workspace = getattr(ctx, "workspace_dir", None)
if new_workspace and new_workspace != agent_holder.get("workspace_dir"):
@@ -608,6 +603,7 @@ def _serve_process_message(
show_thinking: bool,
on_cmd_completed: Callable[..., Awaitable[None]] | None = None,
start_new_session_cb: Callable[[], None] | None = None,
channel_runtime: Any | None = None,
) -> None:
"""Process a single channel message in headless serve mode.
@@ -728,9 +724,10 @@ def _serve_process_message(
checkpointer=None,
append_system=lambda t, s="dim": console.print(t, style=s),
start_new_session_cb=start_new_session_cb
or _make_serve_start_new_session_cb(agent_holder),
or _make_serve_start_new_session_cb(agent_holder, channel_runtime),
on_cmd_completed=on_cmd_completed
or _make_serve_cmd_completed_hook(agent_holder),
or _make_serve_cmd_completed_hook(agent_holder, channel_runtime),
channel_runtime=channel_runtime,
)
)
except Exception as exc:
@@ -887,11 +884,19 @@ def serve(
"workspace_dir": ws,
}
from ..commands.base import ChannelRuntime
channel_runtime = ChannelRuntime(agent=agent, thread_id=tid)
# Build the slash-dispatch callbacks once; the poll loop reuses
# them for every inbound message. Without this hoist each message
# would allocate a fresh closure pair.
_serve_on_cmd_completed = _make_serve_cmd_completed_hook(agent_holder)
_serve_start_new_session_cb = _make_serve_start_new_session_cb(agent_holder)
_serve_on_cmd_completed = _make_serve_cmd_completed_hook(
agent_holder, channel_runtime
)
_serve_start_new_session_cb = _make_serve_start_new_session_cb(
agent_holder, channel_runtime
)
_start_channels_bus_mode(
config,
@@ -946,6 +951,7 @@ def serve(
show_thinking=effective_channel_thinking,
on_cmd_completed=_serve_on_cmd_completed,
start_new_session_cb=_serve_start_new_session_cb,
channel_runtime=channel_runtime,
)
except KeyboardInterrupt:
shutdown_event.set()
@@ -956,7 +962,7 @@ def serve(
signal.signal(signal.SIGINT, _orig_sigint)
signal.signal(signal.SIGTERM, _orig_sigterm)
console.print("\n[dim]Shutting down...[/dim]")
_channels_stop()
_channels_stop(runtime=channel_runtime)
console.print("[dim]Stopped.[/dim]")
+41 -9
View File
@@ -321,6 +321,10 @@ def cmd_interactive(
"status_last_input_tokens": None,
}
from ..commands.base import ChannelRuntime
channel_runtime = ChannelRuntime()
progress_tracker = MCPProgressTracker()
def _on_mcp_progress(event: str, server: str, detail: str) -> None:
@@ -386,8 +390,7 @@ def cmd_interactive(
raise
await _refresh_status_snapshot(reset_streaming_text=True)
if _channels_is_running():
_ch_mod._cli_agent = agent
_ch_mod._cli_thread_id = state["thread_id"]
channel_runtime.bind(agent, state["thread_id"])
return agent
def _rebuild_status_snapshot() -> None:
@@ -833,9 +836,22 @@ def cmd_interactive(
state["status_base_snapshot"] = make_empty_status_snapshot(
model
)
if _channels_is_running():
_ch_mod._cli_agent = ctx.agent
_ch_mod._cli_thread_id = state["thread_id"]
# Rebind the runtime whenever the agent OR
# thread_id may have moved — ``/new`` and
# ``/resume`` rotate ``state["thread_id"]``
# without swapping the agent, and the bus
# expects both to stay in sync (matches the
# serve-mode hook contract).
if _channels_is_running():
runtime_agent = (
ctx.agent
if ctx.agent is not None
else agent_loader.agent
)
if runtime_agent is not None:
channel_runtime.bind(runtime_agent, state["thread_id"])
# ``/new`` rotates ``state["thread_id"]`` / workspace,
# ``/compact`` reduces token usage — both need the
# status snapshot re-rendered even when the agent
@@ -858,6 +874,7 @@ def cmd_interactive(
handle_session_resume_cb=_on_handle_session_resume,
await_agent_ready=_await_agent_ready,
on_cmd_completed=_on_channel_cmd_completed,
channel_runtime=channel_runtime,
)
if _slash_handled:
_print_separator()
@@ -956,6 +973,7 @@ def cmd_interactive(
state["thread_id"],
cfg,
send_thinking=channel_send_thinking,
runtime=channel_runtime,
)
_auto_start_task = asyncio.create_task(
@@ -1036,6 +1054,7 @@ def cmd_interactive(
checkpointer=checkpointer,
config=config,
input_tokens_hint=state.get("status_last_input_tokens"),
channel_runtime=channel_runtime,
)
await cmd_manager.execute(user_input, ctx)
@@ -1046,7 +1065,7 @@ def cmd_interactive(
# Agent swap (e.g. /model successfully built a
# new agent): adopt into loader + reset status
# snapshot + sync channel globals.
# snapshot + sync channel runtime.
agent_swapped = (
ctx.agent is not None
and ctx.agent is not _agent_for_ctx
@@ -1060,9 +1079,22 @@ def cmd_interactive(
state["status_base_snapshot"] = (
make_empty_status_snapshot(model)
)
if _channels_is_running():
_ch_mod._cli_agent = ctx.agent
_ch_mod._cli_thread_id = state["thread_id"]
# Rebind the runtime whenever the agent OR
# thread_id may have moved — ``/new`` /
# ``/resume`` rotate ``state["thread_id"]``
# without swapping the agent, and the bus
# expects both to stay in sync.
if _channels_is_running():
runtime_agent = (
ctx.agent
if ctx.agent is not None
else agent_loader.agent
)
if runtime_agent is not None:
channel_runtime.bind(
runtime_agent, state["thread_id"]
)
# Commands that mutate status fields need an
# async refresh here (/compact + /new use sync
+17 -6
View File
@@ -207,9 +207,15 @@ async def _sync_tui_command_completion(
update_model = getattr(app, "update_status_after_model_change", None)
if callable(update_model):
update_model(cfg.model, cfg.provider)
if _channels_is_running():
_ch_mod._cli_agent = ctx.agent
_ch_mod._cli_thread_id = app._conversation_tid
# Rebind the runtime whenever the agent OR thread_id may have moved
# — ``/new`` and ``/resume`` rotate ``app._conversation_tid``
# without swapping the agent, and the bus expects both to stay in
# sync (matches the serve-mode hook contract).
if _channels_is_running():
runtime_agent = ctx.agent if ctx.agent is not None else app._agent_loader.agent
if runtime_agent is not None:
app._channel_runtime.bind(runtime_agent, app._conversation_tid)
await app._refresh_status_snapshot(reset_streaming_text=True)
@@ -391,6 +397,9 @@ def run_textual_interactive(
self._history_index: int = -1 # -1 = not browsing history
self._history_saved_input: str = "" # saved current input before browsing
self._background_tasks: set[asyncio.Task] = set()
from ..commands.base import ChannelRuntime
self._channel_runtime = ChannelRuntime()
self._quit_pending: bool = False
self._current_model: str | None = model
self._current_provider: str | None = provider
@@ -425,8 +434,7 @@ def run_textual_interactive(
def _on_agent_load_success(self, agent: Any) -> None:
if _channels_is_running():
_ch_mod._cli_agent = agent
_ch_mod._cli_thread_id = self._conversation_tid
self._channel_runtime.bind(agent, self._conversation_tid)
self._finish_loader_widget()
self._render_status()
@@ -730,6 +738,7 @@ def run_textual_interactive(
self._conversation_tid,
cfg,
send_thinking=self._channel_send_thinking,
runtime=self._channel_runtime,
)
types = [
t.strip() for t in cfg.channel_enabled.split(",") if t.strip()
@@ -1915,6 +1924,7 @@ def run_textual_interactive(
handle_session_resume_cb=self.handle_session_resume,
await_agent_ready=self._await_agent_ready,
on_cmd_completed=self._on_channel_cmd_completed,
channel_runtime=self._channel_runtime,
)
if _slash_handled:
return # outer finally handles _busy / widget cleanup
@@ -2372,6 +2382,7 @@ def run_textual_interactive(
workspace_dir=self._workspace_dir,
checkpointer=self._checkpointer,
input_tokens_hint=self._status_last_input_tokens,
channel_runtime=self._channel_runtime,
)
if await cmd_manager.execute(command, ctx):
@@ -2500,7 +2511,7 @@ def run_textual_interactive(
self._started_channel_types.clear()
if _channels_is_running():
try:
_channels_stop()
_channels_stop(runtime=self._channel_runtime)
except Exception:
pass
self.exit()
+17
View File
@@ -51,6 +51,22 @@ class CommandUI(Protocol):
async def flush(self) -> None: ...
@dataclass
class ChannelRuntime:
"""Mutable handle to the agent + thread bound to running channels."""
agent: Any = None
thread_id: str | None = None
def bind(self, agent: Any, thread_id: str | None) -> None:
self.agent = agent
self.thread_id = thread_id
def clear(self) -> None:
self.agent = None
self.thread_id = None
@dataclass
class CommandContext:
"""Context passed to commands during execution."""
@@ -61,6 +77,7 @@ class CommandContext:
workspace_dir: str | None = None
checkpointer: Any = None
config: Any = None
channel_runtime: ChannelRuntime | None = None
# Real LLM input token count from last usage_metadata (includes system
# prompt + tool schemas). Used by /compact for accurate display.
input_tokens_hint: int | None = None
@@ -71,10 +71,10 @@ class ChannelCommand(Command):
ctx.ui.append_system("No channels are running.", style="dim")
else:
if target:
_channels_stop(target)
_channels_stop(target, runtime=ctx.channel_runtime)
ctx.ui.append_system(f"Channel '{target}' stopped.", style="green")
else:
_channels_stop()
_channels_stop(runtime=ctx.channel_runtime)
ctx.ui.append_system("All channels stopped.", style="green")
return
@@ -98,12 +98,11 @@ class ChannelCommand(Command):
ctx.ui.append_system(f"Adding channel(s): {', '.join(requested)}...")
from ...cli.channel import _add_channel_to_running_bus
# Sync CLI-side globals up-front so partial-success states (one
# channel attached, next one raises) still leave
# _auto_start_channel observing the latest agent/thread refs.
# See cli/channel.py _cli_agent / _cli_thread_id.
_ch_mod._cli_agent = ctx.agent
_ch_mod._cli_thread_id = ctx.thread_id
# Bind the runtime up-front so partial-success states (one
# channel attached, next one raises) still leave the bus
# observing the latest agent/thread refs.
if ctx.channel_runtime is not None:
ctx.channel_runtime.bind(ctx.agent, ctx.thread_id)
try:
for ct in requested:
_add_channel_to_running_bus(ct, config, send_thinking=send_thinking)
@@ -137,12 +136,8 @@ class ChannelCommand(Command):
ctx.thread_id,
send_thinking=send_thinking,
)
# Sync CLI-side globals so _auto_start_channel observes the
# latest agent/thread refs (see cli/channel.py _cli_agent /
# _cli_thread_id). The pre-migration inline helper set these
# in the same branch.
_ch_mod._cli_agent = ctx.agent
_ch_mod._cli_thread_id = ctx.thread_id
if ctx.channel_runtime is not None:
ctx.channel_runtime.bind(ctx.agent, ctx.thread_id)
# Show status panel
if _ch_mod._manager:
@@ -217,14 +217,10 @@ class ModelCommand(Command):
set_config_value("model", model_name)
set_config_value("provider", provider)
# Propagate to channel module if channels are running
try:
import EvoScientist.cli.channel as _ch_mod
if getattr(_ch_mod, "_cli_agent", None) is not None:
_ch_mod._cli_agent = new_agent
except Exception:
pass
# Propagate to the channel runtime if channels are running so the
# bus picks up the new agent on the next inbound message.
if ctx.channel_runtime is not None and ctx.channel_runtime.agent is not None:
ctx.channel_runtime.agent = new_agent
# Update status bar if available
update_model_fn = getattr(ctx.ui, "update_status_after_model_change", None)
+16 -32
View File
@@ -3,37 +3,23 @@
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from tests.conftest import run_async as _run
@pytest.fixture(autouse=True)
def _reset_channel_globals():
"""Reset module-level ``_cli_agent``/``_cli_thread_id`` around every test.
These globals are written by the start / add-to-running paths and would
otherwise leak between tests (and into unrelated suites).
"""
import EvoScientist.cli.channel as _ch
_ch._cli_agent = None
_ch._cli_thread_id = None
try:
yield
finally:
_ch._cli_agent = None
_ch._cli_thread_id = None
def _ctx():
from EvoScientist.commands.base import CommandContext
from EvoScientist.commands.base import ChannelRuntime, CommandContext
ui = MagicMock()
ui.supports_interactive = True
return CommandContext(
agent=object(), thread_id="tid-42", ui=ui, workspace_dir="/ws"
), ui
runtime = ChannelRuntime()
ctx = CommandContext(
agent=object(),
thread_id="tid-42",
ui=ui,
workspace_dir="/ws",
channel_runtime=runtime,
)
return ctx, ui
class TestNeedsAgent:
@@ -69,8 +55,7 @@ class TestNeedsAgent:
class TestStartPath:
"""Start flow must propagate agent/thread_id globals."""
def test_start_sets_cli_agent_globals(self):
import EvoScientist.cli.channel as _ch_mod
def test_start_binds_channel_runtime(self):
from EvoScientist.commands.implementation.channel import ChannelCommand
ctx, _ui = _ctx()
@@ -93,8 +78,8 @@ class TestStartPath:
),
):
_run(ChannelCommand().execute(ctx, ["telegram"]))
assert _ch_mod._cli_agent is ctx.agent
assert _ch_mod._cli_thread_id == "tid-42"
assert ctx.channel_runtime.agent is ctx.agent
assert ctx.channel_runtime.thread_id == "tid-42"
def test_start_propagates_send_thinking(self):
"""send_thinking flag must reach _start_channels_bus_mode."""
@@ -133,8 +118,7 @@ class TestStartPath:
class TestAddToRunningPath:
def test_add_to_running_sets_cli_agent_globals(self):
import EvoScientist.cli.channel as _ch_mod
def test_add_to_running_binds_channel_runtime(self):
from EvoScientist.commands.implementation.channel import ChannelCommand
ctx, _ui = _ctx()
@@ -157,8 +141,8 @@ class TestAddToRunningPath:
),
):
_run(ChannelCommand().execute(ctx, ["discord"]))
assert _ch_mod._cli_agent is ctx.agent
assert _ch_mod._cli_thread_id == "tid-42"
assert ctx.channel_runtime.agent is ctx.agent
assert ctx.channel_runtime.thread_id == "tid-42"
def test_add_to_running_propagates_send_thinking(self):
"""Adding to a running bus must honor config.channel_send_thinking."""
+7 -5
View File
@@ -11,23 +11,21 @@ from EvoScientist.cli import channel as channel_cli
@pytest.fixture(autouse=True)
def _restore_channel_globals():
"""Restore mutable module globals after each test."""
"""Restore the bus-mode globals after each test."""
original = {
"_manager": channel_cli._manager,
"_bus_loop": channel_cli._bus_loop,
"_bus_thread": channel_cli._bus_thread,
"_cli_agent": channel_cli._cli_agent,
"_cli_thread_id": channel_cli._cli_thread_id,
}
yield
channel_cli._manager = original["_manager"]
channel_cli._bus_loop = original["_bus_loop"]
channel_cli._bus_thread = original["_bus_thread"]
channel_cli._cli_agent = original["_cli_agent"]
channel_cli._cli_thread_id = original["_cli_thread_id"]
def test_auto_start_channel_passes_send_thinking(monkeypatch):
from EvoScientist.commands.base import ChannelRuntime
captured = {}
def _fake_start(config, agent, thread_id, *, send_thinking=None):
@@ -40,13 +38,17 @@ def test_auto_start_channel_passes_send_thinking(monkeypatch):
config = SimpleNamespace(channel_enabled="telegram")
agent = object()
runtime = ChannelRuntime()
channel_cli._auto_start_channel(
agent,
"thread-1",
config,
send_thinking=False,
runtime=runtime,
)
assert captured["send_thinking"] is False
assert captured["thread_id"] == "thread-1"
assert captured["agent"] is agent
assert runtime.agent is agent
assert runtime.thread_id == "thread-1"
+1 -1
View File
@@ -65,7 +65,7 @@ def _run_serve_once(
captured["send_thinking"] = send_thinking
captured["thread_id"] = thread_id
def _fake_channels_stop():
def _fake_channels_stop(channel_type=None, *, runtime=None):
captured["stopped"] = True
class _InterruptQueue:
+22 -34
View File
@@ -8,8 +8,6 @@ captured at startup.
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from EvoScientist.cli.channel import (
ChannelMessage,
_register_channel_request,
@@ -19,22 +17,10 @@ from EvoScientist.cli.commands import (
_make_serve_start_new_session_cb,
_serve_process_message,
)
from EvoScientist.commands.base import ChannelRuntime
from tests.conftest import run_async as _run
@pytest.fixture(autouse=True)
def _restore_channel_globals():
import EvoScientist.cli.channel as _ch_mod
prev_agent = _ch_mod._cli_agent
prev_tid = _ch_mod._cli_thread_id
try:
yield
finally:
_ch_mod._cli_agent = prev_agent
_ch_mod._cli_thread_id = prev_tid
def test_hook_updates_holder_on_agent_swap():
"""``/model`` mutates ``ctx.agent`` to a new handle — the hook must
push that handle into the shared holder so the outer poll loop sees
@@ -52,22 +38,26 @@ def test_hook_updates_holder_on_agent_swap():
assert holder["agent"] == "new-agent"
def test_hook_syncs_channel_module_global():
"""Other readers (the bus) look at ``cli.channel._cli_agent``; the
hook keeps that global in sync with the holder update."""
import EvoScientist.cli.channel as _ch_mod
holder = {"agent": "original-agent"}
hook = _make_serve_cmd_completed_hook(holder)
def test_hook_syncs_channel_runtime():
"""Other readers (the bus) look at ``ChannelRuntime.agent``; the
hook keeps the runtime in sync with the holder update."""
holder = {"agent": "original-agent", "thread_id": "t"}
runtime = ChannelRuntime(agent="original-agent", thread_id="t")
hook = _make_serve_cmd_completed_hook(holder, runtime)
ctx = MagicMock()
ctx.agent = "new-agent"
# Pin ctx.thread_id explicitly — a bare MagicMock would let the
# hook's getattr fall through to a fresh MagicMock attribute and
# silently mutate runtime.thread_id, hiding regressions.
ctx.thread_id = "t"
cmd = MagicMock()
cmd.name = "/model"
_run(hook(ctx, "original-agent", cmd))
assert _ch_mod._cli_agent == "new-agent"
assert runtime.agent == "new-agent"
assert runtime.thread_id == "t"
def test_hook_noop_when_agent_unchanged():
@@ -137,13 +127,12 @@ def test_hook_updates_workspace_dir_on_resume():
assert holder["workspace_dir"] == "/restored-ws"
def test_hook_syncs_channel_module_thread_id():
"""The bus reads ``cli.channel._cli_thread_id``; hook must sync it
def test_hook_syncs_channel_runtime_thread_id():
"""The bus reads ``ChannelRuntime.thread_id``; hook must sync it
alongside the holder update."""
import EvoScientist.cli.channel as _ch_mod
holder = {"agent": "a", "thread_id": "original-tid"}
hook = _make_serve_cmd_completed_hook(holder)
runtime = ChannelRuntime(agent="a", thread_id="original-tid")
hook = _make_serve_cmd_completed_hook(holder, runtime)
ctx = MagicMock()
ctx.agent = "a"
@@ -153,7 +142,7 @@ def test_hook_syncs_channel_module_thread_id():
_run(hook(ctx, "a", cmd))
assert _ch_mod._cli_thread_id == "new-tid"
assert runtime.thread_id == "new-tid"
def test_hook_noop_when_thread_id_unchanged():
@@ -220,20 +209,19 @@ def test_hook_emits_resume_warning_when_thread_changed():
def test_start_new_session_cb_rotates_thread_id():
"""``/new`` via channel calls this callback — must generate a new
thread id, push into holder, and sync the channel-module global."""
import EvoScientist.cli.channel as _ch_mod
thread id, push into holder, and sync the channel runtime."""
holder = {"agent": "a", "thread_id": "old-tid"}
runtime = ChannelRuntime(agent="a", thread_id="old-tid")
with patch(
"EvoScientist.sessions.generate_thread_id",
return_value="freshly-generated-tid",
):
cb = _make_serve_start_new_session_cb(holder)
cb = _make_serve_start_new_session_cb(holder, runtime)
cb()
assert holder["thread_id"] == "freshly-generated-tid"
assert _ch_mod._cli_thread_id == "freshly-generated-tid"
assert runtime.thread_id == "freshly-generated-tid"
def test_start_new_session_cb_leaves_agent_alone():
+32 -5
View File
@@ -4,7 +4,7 @@ from types import SimpleNamespace
import pytest
from EvoScientist.commands.base import CommandContext
from EvoScientist.commands.base import ChannelRuntime, CommandContext
from tests.conftest import run_async as _run
pytest.importorskip("textual")
@@ -13,15 +13,18 @@ pytest.importorskip("textual")
class _Loader:
def __init__(self) -> None:
self.adopt_calls: list[object] = []
self.agent: object | None = None
def adopt(self, agent: object) -> None:
self.adopt_calls.append(agent)
self.agent = agent
class _StubApp:
def __init__(self) -> None:
self._agent_loader = _Loader()
self._conversation_tid = "thread-1"
self._channel_runtime = ChannelRuntime()
self.model_updates: list[tuple[str, str | None]] = []
self.refresh_calls: list[bool] = []
@@ -58,16 +61,15 @@ def test_sync_tui_command_completion_adopts_agent_swap(monkeypatch):
lambda: SimpleNamespace(model="gpt-5.5", provider="openai"),
)
monkeypatch.setattr(tui_mod, "_channels_is_running", lambda: True)
monkeypatch.setattr(tui_mod._ch_mod, "_cli_agent", "old-agent", raising=False)
monkeypatch.setattr(tui_mod._ch_mod, "_cli_thread_id", "old-thread", raising=False)
app._channel_runtime.bind("old-agent", "old-thread")
_run(tui_mod._sync_tui_command_completion(app, ctx, "old-agent", cmd))
assert app._agent_loader.adopt_calls == ["new-agent"]
assert app.model_updates == [("gpt-5.5", "openai")]
assert app.refresh_calls == [True]
assert tui_mod._ch_mod._cli_agent == "new-agent"
assert tui_mod._ch_mod._cli_thread_id == "thread-1"
assert app._channel_runtime.agent == "new-agent"
assert app._channel_runtime.thread_id == "thread-1"
def test_sync_tui_command_completion_refreshes_without_agent_swap(monkeypatch):
@@ -88,3 +90,28 @@ def test_sync_tui_command_completion_refreshes_without_agent_swap(monkeypatch):
assert app._agent_loader.adopt_calls == []
assert app.model_updates == []
assert app.refresh_calls == [True]
def test_sync_tui_rebinds_runtime_on_thread_rotation_without_agent_swap(monkeypatch):
"""Regression: ``/new`` and ``/resume`` rotate ``app._conversation_tid``
without swapping the agent. The runtime must still pick up the new
thread id so the bus contract stays consistent with serve mode."""
import EvoScientist.cli.tui_interactive as tui_mod
app = _StubApp()
app._conversation_tid = "rotated-thread"
app._agent_loader.agent = "same-agent"
app._channel_runtime.bind("same-agent", "old-thread")
ctx = CommandContext(
agent="same-agent",
thread_id="rotated-thread",
ui=SimpleNamespace(),
)
cmd = SimpleNamespace(name="/new")
monkeypatch.setattr(tui_mod, "_channels_is_running", lambda: True)
_run(tui_mod._sync_tui_command_completion(app, ctx, "same-agent", cmd))
assert app._channel_runtime.agent == "same-agent"
assert app._channel_runtime.thread_id == "rotated-thread"