1 Commits

Author SHA1 Message Date
m4 e0acc6155e feat: improve WebUI run recovery
Build / build (push) Has been cancelled
Docker / build (push) Has been cancelled
Lint / ruff (push) Has been cancelled
Test / pytest (ubuntu-latest, 3.11) (push) Has been cancelled
Test / pytest (ubuntu-latest, 3.12) (push) Has been cancelled
Test / pytest (windows-latest, 3.11) (push) Has been cancelled
Test / pytest (windows-latest, 3.12) (push) Has been cancelled
2026-07-10 17:35:44 +08:00
98 changed files with 4802 additions and 5078 deletions
+1 -1
View File
@@ -5,5 +5,5 @@
<rect x="54" y="5" width="62" height="24" rx="6" fill="#1565c0"/>
<text x="85" y="22" text-anchor="middle"
font-family="Inter, -apple-system, system-ui, sans-serif"
font-size="13" font-weight="700" fill="#ffffff">v0.2.2</text>
font-size="13" font-weight="700" fill="#ffffff">v0.2.1</text>
</svg>

Before

Width:  |  Height:  |  Size: 555 B

After

Width:  |  Height:  |  Size: 555 B

+1 -1
View File
@@ -5,5 +5,5 @@
<rect x="54" y="5" width="62" height="24" rx="6" fill="#2563eb"/>
<text x="85" y="22" text-anchor="middle"
font-family="Inter, -apple-system, system-ui, sans-serif"
font-size="13" font-weight="700" fill="#ffffff">v0.2.2</text>
font-size="13" font-weight="700" fill="#ffffff">v0.2.1</text>
</svg>

Before

Width:  |  Height:  |  Size: 555 B

After

Width:  |  Height:  |  Size: 555 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

After

Width:  |  Height:  |  Size: 286 KiB

+1
View File
@@ -48,3 +48,4 @@ conversation_history/
*meals/
botpy.log
large_tool_results/
runs/
+20 -57
View File
@@ -641,13 +641,9 @@ def _get_default_middleware(
*,
for_async_subagent: bool = False,
workspace_dir: str | Path | None = None,
memory_dir: str | Path | None = None,
cfg=None,
chat_model=None,
memory_source_agent: str = "EvoScientist",
tool_selector_threshold: int | None = None,
memory_max_inline_profile_chars: int | None = None,
enable_background_execution: bool = True,
):
"""Build the default middleware list.
@@ -688,7 +684,7 @@ def _get_default_middleware(
if cfg.model_fallbacks:
load_fallback_chain(cfg.model_fallbacks)
model = chat_model if chat_model is not None else _ensure_chat_model()
memory_dir = str(memory_dir or _paths_mod.MEMORIES_DIR)
memory_dir = str(_paths_mod.MEMORIES_DIR)
source_type = (
MemorySourceType.SUBAGENT if for_async_subagent else MemorySourceType.TURN
)
@@ -703,20 +699,18 @@ def _get_default_middleware(
# ``ModelFallbackMiddleware``: a configurable.model override sets the
# PRIMARY model only, leaving the fallback chain free to try its own
# alternatives instead of re-overriding every retry to the same model.
memory_kwargs = {
"workspace_dir": workspace_dir,
"source_type": source_type,
"source_agent": memory_source_agent,
"enable_profile_memory": memory_controls.profile_enabled,
"enable_observation_memory": memory_controls.observations_enabled,
"enable_observation_tool": memory_controls.observation_tool_enabled(
memory_middleware = create_memory_middleware(
memory_dir,
workspace_dir=workspace_dir,
source_type=source_type,
source_agent=memory_source_agent,
enable_profile_memory=memory_controls.profile_enabled,
enable_observation_memory=memory_controls.observations_enabled,
enable_observation_tool=memory_controls.observation_tool_enabled(
MemoryObservationTarget.AGENT
),
"memory_scheduler": memory_scheduler,
}
if memory_max_inline_profile_chars is not None:
memory_kwargs["max_inline_profile_chars"] = memory_max_inline_profile_chars
memory_middleware = create_memory_middleware(memory_dir, **memory_kwargs)
memory_scheduler=memory_scheduler,
)
# Main-agent tool selection may use the auxiliary model; async sub-agents
# keep the main model (they do real work, not a one-off helper call).
# context_editing stays on the main model — its model only sizes the
@@ -741,11 +735,6 @@ def _get_default_middleware(
ContextOverflowMapperMiddleware(),
ToolErrorHandlerMiddleware(),
*create_tool_selector_middleware(
**(
{"threshold": tool_selector_threshold}
if tool_selector_threshold is not None
else {}
),
model=tool_selector_model,
track_stream_selection=not for_async_subagent,
),
@@ -781,7 +770,7 @@ def _get_default_middleware(
# Background-process tools (run_in_background / check_process / stop_process /
# list_processes) — main agent only. Async sub-agents run on langgraph-dev and
# must not spawn local OS processes.
if not for_async_subagent and enable_background_execution:
if not for_async_subagent:
from .middleware.background import BackgroundExecutionMiddleware
mw.append(BackgroundExecutionMiddleware())
@@ -879,12 +868,6 @@ def create_cli_agent(
chat_model=None,
*,
on_mcp_progress=None,
workspace_backend=None,
memory_dir: str | Path | None = None,
tool_selector_threshold: int | None = None,
memory_max_inline_profile_chars: int | None = None,
enable_subagents: bool = True,
enable_background_execution: bool = True,
) -> "CompiledStateGraph":
"""Create agent with checkpointer for CLI multi-turn support.
@@ -911,16 +894,6 @@ def create_cli_agent(
chat_model: Optional pre-built chat model. Only triggers the pure
path when ``config`` is also explicit; otherwise it is ignored in
favor of the ``_ensure_chat_model()`` fallback.
workspace_backend: Optional host-provided backend for the workspace
route. The default remains ``CustomSandboxBackend``.
memory_dir: Optional memory root used by both the backend route and
memory middleware.
tool_selector_threshold: Optional adaptive tool-selection threshold.
memory_max_inline_profile_chars: Optional memory profile injection cap.
enable_subagents: Whether configured subagents are available to the agent.
enable_background_execution: Whether local background-process tools are
installed. Embedding hosts should disable this when process execution
is provided by an external backend.
"""
import os as _os
@@ -962,21 +935,19 @@ def create_cli_agent(
workspace_dir = str(_paths.WORKSPACE_ROOT)
# Read paths dynamically so runtime set_workspace_root() changes are picked up
_mem_dir = str(memory_dir or _paths.MEMORIES_DIR)
_mem_dir = str(_paths.MEMORIES_DIR)
_usr_skills_dir = str(_paths.USER_SKILLS_DIR)
_global_skills_dir = str(_paths.GLOBAL_SKILLS_DIR)
# Always construct fresh backends from current paths (avoids stale
# module-level backend when workspace root changed at runtime).
set_active_workspace(workspace_dir)
ws_backend = workspace_backend
if ws_backend is None:
ws_backend = CustomSandboxBackend(
root_dir=workspace_dir,
virtual_mode=True,
timeout=cfg.sandbox_execute_timeout,
dangerous=cfg.dangerous_mode,
)
ws_backend = CustomSandboxBackend(
root_dir=workspace_dir,
virtual_mode=True,
timeout=cfg.sandbox_execute_timeout,
dangerous=cfg.dangerous_mode,
)
sk_backend = MergedSkillsBackend(
primary_dir=_usr_skills_dir,
global_dir=_global_skills_dir,
@@ -998,13 +969,7 @@ def create_cli_agent(
# CLI agent never drifts from the default chain. Anything CLI-specific
# (e.g. ``HumanInTheLoopMiddleware``) is appended below.
mw: list[AgentMiddleware] = _get_default_middleware(
workspace_dir=workspace_dir,
memory_dir=_mem_dir,
cfg=cfg,
chat_model=chat_model,
tool_selector_threshold=tool_selector_threshold,
memory_max_inline_profile_chars=memory_max_inline_profile_chars,
enable_background_execution=enable_background_execution,
workspace_dir=workspace_dir, cfg=cfg, chat_model=chat_model
)
# HITL on main agent only — passing `interrupt_on=` to create_deep_agent
@@ -1030,8 +995,6 @@ def create_cli_agent(
chat_model=chat_model,
workspace_dir=workspace_dir,
)
if not enable_subagents:
kwargs = {**kwargs, "subagents": []}
return create_deep_agent(
**kwargs,
-2
View File
@@ -9,8 +9,6 @@ from __future__ import annotations
from importlib import import_module
__version__ = "0.2.2"
_EXPORTS: dict[str, tuple[str, str]] = {
# Agent graph (lazy to avoid expensive initialization at import time)
"EvoScientist_agent": (".EvoScientist", "EvoScientist_agent"),
+5 -10
View File
@@ -75,13 +75,11 @@ class DedupCache:
max_size: int = _DEDUP_MAX,
trim_to: int = _DEDUP_TRIM,
ttl_seconds: float = _DEDUP_TTL,
clock: Callable[[], float] | None = None,
) -> None:
self._seen: OrderedDict[str, float] = OrderedDict()
self._max = max_size
self._trim = trim_to
self._ttl = ttl_seconds
self._clock = clock or time.monotonic
# ── public API ──────────────────────────────────────────────────
@@ -95,16 +93,15 @@ class DedupCache:
if not msg_id:
return False
now = self._clock()
self._prune(now)
self._prune()
if msg_id in self._seen:
# LRU: refresh position and timestamp
self._seen.move_to_end(msg_id)
self._seen[msg_id] = now
self._seen[msg_id] = time.monotonic()
return True
self._seen[msg_id] = now
self._seen[msg_id] = time.monotonic()
if len(self._seen) > self._max:
while len(self._seen) > self._trim:
self._seen.popitem(last=False)
@@ -121,9 +118,9 @@ class DedupCache:
# ── internal ────────────────────────────────────────────────────
def _prune(self, now: float | None = None) -> None:
def _prune(self) -> None:
"""Remove entries older than *ttl_seconds*."""
cutoff = (self._clock() if now is None else now) - self._ttl
cutoff = time.monotonic() - self._ttl
# OrderedDict is insertion-ordered; oldest entries are first.
while self._seen:
_key, ts = next(iter(self._seen.items()))
@@ -431,13 +428,11 @@ class DedupMiddleware(InboundMiddleware):
max_size: int = 1000,
trim_to: int = 500,
ttl_seconds: float = 3600.0,
clock: Callable[[], float] | None = None,
) -> None:
self._cache = DedupCache(
max_size=max_size,
trim_to=trim_to,
ttl_seconds=ttl_seconds,
clock=clock,
)
async def process_inbound(
+109 -66
View File
@@ -371,31 +371,14 @@ def _step_minimax_region(config: EvoScientistConfig) -> str:
return _MINIMAX_REGIONS[region]
def _step_oauth_auth_mode(
config: EvoScientistConfig,
*,
provider_label: str,
ccproxy_provider: str,
config_attr: str,
prompt_login_label: str,
oauth_choice_label: str | None = None,
status_label: str | None = None,
question_label: str | None = None,
) -> str:
"""Select API-key vs ccproxy OAuth authentication for a provider.
def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str:
"""Step 2a: Select Anthropic authentication mode (API key vs OAuth).
Args:
config: Current configuration.
provider_label: Provider display name for direct API-key access.
ccproxy_provider: ccproxy auth provider name.
config_attr: Config attribute storing this provider's auth mode.
prompt_login_label: Label used in "Log in to ..." prompts.
oauth_choice_label: Optional display label for the OAuth choice.
status_label: Optional display label for status messages.
question_label: Optional prompt label override.
Returns:
Selected auth mode: "api_key" or "oauth".
Selected auth mode: "api_key", "oauth", or "auto".
"""
from ...ccproxy_manager import check_ccproxy_auth, is_ccproxy_available
@@ -403,14 +386,10 @@ def _step_oauth_auth_mode(
from .prompter import BACK_SENTINEL, GoBack, install_navigation_keys
oauth_label = oauth_choice_label or f"{prompt_login_label} OAuth"
auth_status_label = status_label or oauth_label
auth_question_label = question_label or f"{provider_label} authentication mode"
choices = [
Choice(title=f"API Key (direct {provider_label} access)", value="api_key"),
Choice(title="API Key (direct Anthropic access)", value="api_key"),
Choice(
title=f"{oauth_label} (via ccproxy — no API key needed)"
title="Claude Code OAuth (via ccproxy — no API key needed)"
+ (
""
if ccproxy_available
@@ -422,12 +401,12 @@ def _step_oauth_auth_mode(
Choice(title="← Back (re-pick provider)", value=BACK_SENTINEL),
]
current = getattr(config, config_attr)
current = config.anthropic_auth_mode
if current not in ("api_key", "oauth"):
current = "api_key"
question = questionary.select(
f"{auth_question_label} [Esc/← to go back]:",
"Authentication mode [Esc/← to go back]:",
choices=choices,
default=current,
style=WIZARD_STYLE,
@@ -469,9 +448,11 @@ def _step_oauth_auth_mode(
if auth_mode == "oauth":
_prompt_ccproxy_port(config)
authed, msg = check_ccproxy_auth(ccproxy_provider)
# If OAuth selected, check auth status and offer login
if auth_mode in ("oauth", "auto"):
authed, msg = check_ccproxy_auth()
if authed:
console.print(f" [green]✓ {auth_status_label}: {msg}[/green]")
console.print(f" [green]✓ OAuth: {msg}[/green]")
relogin = questionary.confirm(
"Re-authenticate to refresh credentials?",
default=False,
@@ -481,13 +462,11 @@ def _step_oauth_auth_mode(
if relogin is None:
raise KeyboardInterrupt()
if relogin:
_run_ccproxy_login(ccproxy_provider, auth_status_label)
_run_ccproxy_login("claude_api", "OAuth")
else:
console.print(
f" [yellow]{auth_status_label} not authenticated: {msg}[/yellow]"
)
console.print(f" [yellow]OAuth not authenticated: {msg}[/yellow]")
login = questionary.confirm(
f"Log in to {prompt_login_label} now?",
"Log in to Claude now?",
default=True,
style=CONFIRM_STYLE,
qmark=QMARK,
@@ -495,32 +474,11 @@ def _step_oauth_auth_mode(
if login is None:
raise KeyboardInterrupt()
if login:
_run_ccproxy_login(ccproxy_provider, auth_status_label)
_run_ccproxy_login("claude_api", "OAuth")
return auth_mode
def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str:
"""Step 2a: Select Anthropic authentication mode (API key vs OAuth).
Args:
config: Current configuration.
Returns:
Selected auth mode: "api_key" or "oauth".
"""
return _step_oauth_auth_mode(
config,
provider_label="Anthropic",
ccproxy_provider="claude_api",
config_attr="anthropic_auth_mode",
prompt_login_label="Claude",
oauth_choice_label="Claude Code OAuth",
status_label="OAuth",
question_label="Authentication mode",
)
def _step_openai_auth_mode(config: EvoScientistConfig) -> str:
"""Step 2b: Select OpenAI authentication mode (API key vs Codex OAuth).
@@ -530,16 +488,101 @@ def _step_openai_auth_mode(config: EvoScientistConfig) -> str:
Returns:
Selected auth mode: "api_key" or "oauth".
"""
return _step_oauth_auth_mode(
config,
provider_label="OpenAI",
ccproxy_provider="codex",
config_attr="openai_auth_mode",
prompt_login_label="Codex",
oauth_choice_label="Codex OAuth",
status_label="Codex OAuth",
question_label="OpenAI authentication mode",
from ...ccproxy_manager import check_ccproxy_auth, is_ccproxy_available
ccproxy_available = is_ccproxy_available()
from .prompter import BACK_SENTINEL, GoBack, install_navigation_keys
choices = [
Choice(title="API Key (direct OpenAI access)", value="api_key"),
Choice(
title="Codex OAuth (via ccproxy — no API key needed)"
+ (
""
if ccproxy_available
else " [requires: pip install evoscientist[oauth]]"
),
value="oauth",
),
questionary.Separator(),
Choice(title="← Back (re-pick provider)", value=BACK_SENTINEL),
]
current = config.openai_auth_mode
if current not in ("api_key", "oauth"):
current = "api_key"
question = questionary.select(
"OpenAI authentication mode [Esc/← to go back]:",
choices=choices,
default=current,
style=WIZARD_STYLE,
qmark=QMARK,
use_indicator=True,
)
install_navigation_keys(question, with_back=True)
auth_mode = question.ask()
if auth_mode is None:
raise KeyboardInterrupt()
if auth_mode == BACK_SENTINEL:
raise GoBack()
if auth_mode == "oauth" and not ccproxy_available:
console.print(" [yellow]✗ ccproxy not installed[/yellow]")
console.print()
install = questionary.confirm(
'Install ccproxy now? (pip install "evoscientist[oauth]")',
default=True,
style=WIZARD_STYLE,
qmark=f" {QMARK}",
).ask()
if install is None:
raise KeyboardInterrupt()
if install:
console.print()
if _install_ccproxy():
console.print(" [green]✓ ccproxy installed successfully.[/green]")
else:
console.print(" [yellow]Falling back to API key mode.[/yellow]")
return "api_key"
else:
console.print(
' [dim]Skipped. Install manually: pip install "evoscientist[oauth]"[/dim]'
)
return "api_key"
# If OAuth selected, prompt for port and check auth status
if auth_mode == "oauth":
_prompt_ccproxy_port(config)
authed, msg = check_ccproxy_auth("codex")
if authed:
console.print(f" [green]✓ Codex OAuth: {msg}[/green]")
relogin = questionary.confirm(
"Re-authenticate to refresh credentials?",
default=False,
style=CONFIRM_STYLE,
qmark=QMARK,
).ask()
if relogin is None:
raise KeyboardInterrupt()
if relogin:
_run_ccproxy_login("codex", "Codex OAuth")
else:
console.print(f" [yellow]Codex OAuth not authenticated: {msg}[/yellow]")
login = questionary.confirm(
"Log in to Codex now?",
default=True,
style=CONFIRM_STYLE,
qmark=QMARK,
).ask()
if login is None:
raise KeyboardInterrupt()
if login:
_run_ccproxy_login("codex", "Codex OAuth")
return auth_mode
def _step_provider_api_key(
+181 -258
View File
@@ -129,12 +129,6 @@ _PROVIDER_KEY_ATTR = {
"custom-anthropic": "custom_anthropic_api_key",
}
_MINIMAX_GLOBAL_BASE_URL = "https://api.minimax.io/anthropic"
_CUSTOM_PROVIDER_BASE_URL = {
"custom-openai": ("custom_openai_base_url", "CUSTOM_OPENAI_BASE_URL"),
"custom-anthropic": ("custom_anthropic_base_url", "CUSTOM_ANTHROPIC_BASE_URL"),
}
def _autosave(config: EvoScientistConfig) -> None:
"""Persist current config to disk between phases.
@@ -148,201 +142,6 @@ def _autosave(config: EvoScientistConfig) -> None:
pass
def _configure_provider_base_url(
config: EvoScientistConfig,
provider: str,
*,
strict: bool,
) -> list[str]:
"""Configure provider-specific base URL/region and return Ollama models."""
if provider in _CUSTOM_PROVIDER_BASE_URL:
attr_name, env_name = _CUSTOM_PROVIDER_BASE_URL[provider]
current_base_url = getattr(config, attr_name) or os.environ.get(env_name, "")
if strict:
if not current_base_url:
raise RuntimeError(
f"--non-interactive: {provider} provider needs a base URL. "
f"Set the {env_name} env var or run without --non-interactive."
)
setattr(config, attr_name, current_base_url)
else:
setattr(
config,
attr_name,
_step_base_url(config, current_value=current_base_url),
)
elif provider == "minimax":
if strict:
config.minimax_base_url = (
config.minimax_base_url or _MINIMAX_GLOBAL_BASE_URL
)
else:
config.minimax_base_url = _step_minimax_region(config)
elif provider == "ollama":
if strict:
config.ollama_base_url = (
config.ollama_base_url
or os.environ.get("OLLAMA_BASE_URL", "")
or "http://localhost:11434"
)
else:
ollama_url, ollama_detected_models = _step_ollama_base_url(config)
config.ollama_base_url = ollama_url
return ollama_detected_models
return []
def _configure_provider_auth_mode(
config: EvoScientistConfig,
provider: str,
*,
strict: bool,
) -> None:
"""Configure Anthropic/OpenAI auth mode for the selected provider."""
if provider == "anthropic":
if strict:
config.anthropic_auth_mode = "api_key"
else:
config.anthropic_auth_mode = _step_anthropic_auth_mode(config)
elif provider == "openai":
if strict:
config.openai_auth_mode = "api_key"
else:
config.openai_auth_mode = _step_openai_auth_mode(config)
def _active_llm_providers(config: EvoScientistConfig) -> set[str]:
"""Return providers currently selected by the main and auxiliary models."""
providers = {config.provider}
if config.auxiliary_provider:
providers.add(config.auxiliary_provider)
return providers
def _reconcile_oauth_modes(config: EvoScientistConfig) -> None:
"""Clear OAuth flags for providers no selected model uses."""
active_providers = _active_llm_providers(config)
if "anthropic" not in active_providers:
config.anthropic_auth_mode = "api_key"
if "openai" not in active_providers:
config.openai_auth_mode = "api_key"
def _provider_uses_oauth(config: EvoScientistConfig, provider: str) -> bool:
return (provider == "anthropic" and config.anthropic_auth_mode == "oauth") or (
provider == "openai" and config.openai_auth_mode == "oauth"
)
def _apply_preset_provider_api_key(
config: EvoScientistConfig,
provider: str,
preset_api_key: str,
*,
skip_validation: bool,
) -> None:
"""Validate and store a CLI-supplied provider API key."""
if not skip_validation:
from .helpers import _provider_key_info
_info = _provider_key_info(config, provider)
validate_fn = _info[2] if _info else None
if validate_fn is not None:
console.print(" [dim]Validating preset API key...[/dim]", end="")
valid, msg = validate_fn(preset_api_key)
if valid:
console.print(f"\r [green]✓ {msg}[/green] ")
else:
console.print(f"\r [red]✗ {msg}[/red] ")
raise RuntimeError(
f"--api-key rejected by {provider} validator: {msg}. "
"Pass --skip-validation to override."
)
key_attr = _PROVIDER_KEY_ATTR.get(provider, "openai_api_key")
setattr(config, key_attr, preset_api_key)
console.print(
f" [green]✓ API key: ***{preset_api_key[-4:]}[/green] [dim](--api-key)[/dim]"
)
def _configure_provider_api_key(
config: EvoScientistConfig,
provider: str,
*,
skip_validation: bool,
preset_api_key: str | None = None,
require_api_key=None,
) -> None:
"""Configure provider API key unless the provider does not need one."""
if provider == "ollama" or _provider_uses_oauth(config, provider):
return
key_attr = _PROVIDER_KEY_ATTR.get(provider, "openai_api_key")
if preset_api_key is not None:
_apply_preset_provider_api_key(
config,
provider,
preset_api_key,
skip_validation=skip_validation,
)
return
if require_api_key is not None:
require_api_key()
new_key = _step_provider_api_key(config, provider, skip_validation)
if new_key is not None:
setattr(config, key_attr, new_key)
elif not getattr(config, key_attr):
_print_step_skipped("API Key", "not set")
def _provider_connection_configured(config: EvoScientistConfig, provider: str) -> bool:
"""Return True when provider-level setup can be safely reused."""
if provider == "ollama":
return bool(config.ollama_base_url)
if provider == "custom-openai" and not config.custom_openai_base_url:
return False
if provider == "custom-anthropic" and not config.custom_anthropic_base_url:
return False
if provider == "minimax" and not config.minimax_base_url:
return False
if _provider_uses_oauth(config, provider):
return True
key_attr = _PROVIDER_KEY_ATTR.get(provider, "openai_api_key")
return bool(getattr(config, key_attr))
def _configure_provider_connection(
config: EvoScientistConfig,
provider: str,
*,
strict: bool,
skip_validation: bool,
preset_api_key: str | None = None,
require_api_key=None,
) -> list[str]:
"""Configure provider base URL/region, auth mode, and API key."""
ollama_detected_models = _configure_provider_base_url(
config,
provider,
strict=strict,
)
_configure_provider_auth_mode(
config,
provider,
strict=strict,
)
_configure_provider_api_key(
config,
provider,
skip_validation=skip_validation,
preset_api_key=preset_api_key,
require_api_key=require_api_key,
)
return ollama_detected_models
# Sections offered in Keep/Modify/Reset → which step labels they enable.
_SECTION_LABELS: list[tuple[str, str]] = [
("ui", "UI backend"),
@@ -680,17 +479,102 @@ def run_onboard(
provider = _step_provider(config)
config.provider = provider
try:
ollama_detected_models = _configure_provider_connection(
config,
provider,
strict=strict,
skip_validation=skip_validation,
preset_api_key=_preset("api_key"),
require_api_key=lambda provider=provider: _require(
"api_key", f"{provider} API key"
),
# Step 2a: Base URL (custom-openai, custom-anthropic,
# minimax, ollama). In strict non-interactive mode we
# never call the interactive _step_base_url /
# _step_minimax_region / _step_ollama_base_url helpers —
# fall back to the existing config value or the
# CUSTOM_*_BASE_URL / OLLAMA_BASE_URL env var instead.
# If neither is set for a provider that needs it, raise
# so the user sees the same "missing required answer"
# error as for other required prompts.
if provider == "custom-openai":
current_base_url = (
config.custom_openai_base_url
or os.environ.get("CUSTOM_OPENAI_BASE_URL", "")
)
if strict:
if not current_base_url:
raise RuntimeError(
"--non-interactive: custom-openai provider "
"needs a base URL. Set the "
"CUSTOM_OPENAI_BASE_URL env var or run "
"without --non-interactive."
)
config.custom_openai_base_url = current_base_url
else:
config.custom_openai_base_url = _step_base_url(
config, current_value=current_base_url
)
elif provider == "custom-anthropic":
current_base_url = (
config.custom_anthropic_base_url
or os.environ.get("CUSTOM_ANTHROPIC_BASE_URL", "")
)
if strict:
if not current_base_url:
raise RuntimeError(
"--non-interactive: custom-anthropic "
"provider needs a base URL. Set the "
"CUSTOM_ANTHROPIC_BASE_URL env var or run "
"without --non-interactive."
)
config.custom_anthropic_base_url = current_base_url
else:
config.custom_anthropic_base_url = _step_base_url(
config, current_value=current_base_url
)
elif provider == "minimax":
if strict:
# MiniMax has 2 region URLs; default to whatever
# is already in config, else the Global endpoint.
config.minimax_base_url = (
config.minimax_base_url
or "https://api.minimax.io/anthropic"
)
else:
config.minimax_base_url = _step_minimax_region(config)
elif provider == "ollama":
if strict:
# Ollama: existing config value > env var >
# localhost default. Skip the live connection
# validation under strict — model discovery
# happens at runtime anyway.
config.ollama_base_url = (
config.ollama_base_url
or os.environ.get("OLLAMA_BASE_URL", "")
or "http://localhost:11434"
)
# ollama_detected_models stays [] — model picker
# will fall back to free-text or the preset.
else:
ollama_url, ollama_detected_models = _step_ollama_base_url(
config
)
config.ollama_base_url = ollama_url
# Step 2b: Auth mode (Anthropic or OpenAI — API key vs OAuth).
# In strict non-interactive mode we assume "api_key".
# The prompt offers a `← Back` choice that raises GoBack so
# the user can re-pick the provider without exiting the wizard.
try:
if provider == "anthropic":
if strict:
config.anthropic_auth_mode = "api_key"
else:
config.anthropic_auth_mode = _step_anthropic_auth_mode(
config
)
elif provider == "openai":
if strict:
config.openai_auth_mode = "api_key"
else:
config.openai_auth_mode = _step_openai_auth_mode(config)
else:
# Non-Anthropic/OpenAI provider: reset OAuth modes to
# avoid stale oauth config triggering ccproxy at startup.
config.anthropic_auth_mode = "api_key"
config.openai_auth_mode = "api_key"
except GoBack:
# User picked "← Back" — restore config to its state at the
# top of this iteration (drops any base_url / region /
@@ -710,9 +594,60 @@ def run_onboard(
ollama_detected_models = []
console.print(" [dim]↩ Returning to provider selection.[/dim]")
continue
break # Provider setup succeeded — exit sub-loop
break # auth_mode succeeded — exit sub-loop
_reconcile_oauth_modes(config)
# Step 2c: Provider API Key (skip for Ollama and pure OAuth)
_skip_api_key = (
provider == "ollama"
or (
provider == "anthropic"
and config.anthropic_auth_mode == "oauth"
)
or (provider == "openai" and config.openai_auth_mode == "oauth")
)
if not _skip_api_key:
key_attr = _PROVIDER_KEY_ATTR.get(provider, "openai_api_key")
preset_api_key = _preset("api_key")
if preset_api_key is not None:
# Validate the preset key against the same validator
# the interactive path uses, unless --skip-validation
# was passed. Interactive flow shows a "Save anyway?"
# confirm on failure; the non-interactive path has no
# way to ask, so a failed validation is fatal.
if not skip_validation:
from .helpers import _provider_key_info
_info = _provider_key_info(config, provider)
validate_fn = _info[2] if _info else None
if validate_fn is not None:
console.print(
" [dim]Validating preset API key...[/dim]",
end="",
)
valid, msg = validate_fn(preset_api_key)
if valid:
console.print(f"\r [green]✓ {msg}[/green] ")
else:
console.print(f"\r [red]✗ {msg}[/red] ")
raise RuntimeError(
f"--api-key rejected by {provider} "
f"validator: {msg}. Pass "
"--skip-validation to override."
)
setattr(config, key_attr, preset_api_key)
console.print(
f" [green]✓ API key: ***{preset_api_key[-4:]}[/green]"
" [dim](--api-key)[/dim]"
)
else:
_require("api_key", f"{provider} API key")
new_key = _step_provider_api_key(
config, provider, skip_validation
)
if new_key is not None:
setattr(config, key_attr, new_key)
elif not getattr(config, key_attr):
_print_step_skipped("API Key", "not set")
_autosave(config)
else:
# Provider section skipped — keep prior provider value to drive
@@ -745,55 +680,44 @@ def run_onboard(
"kept current" if config.auxiliary_model else "not set",
)
elif _step_auxiliary_enable(config):
from .prompter import GoBack
aux_ollama_detected_models: list[str] = []
while True:
loop_snapshot = copy.deepcopy(config)
aux_provider = _step_provider(
# Assemble: pick provider -> base URL (custom) -> key -> model,
# mirroring the main flow's order. Keys/base URLs are stored
# per provider, so when the auxiliary provider matches the main
# one they're already set and the user just keeps them (Enter).
# Ollama needs no key. Re-runs default to the saved auxiliary
# provider/model rather than the main ones.
aux_provider = _step_provider(
config,
label="co-pilot",
default_value=config.auxiliary_provider,
)
config.auxiliary_provider = aux_provider
if aux_provider == "custom-openai":
config.custom_openai_base_url = _step_base_url(
config,
label="co-pilot",
default_value=config.auxiliary_provider,
current_value=config.custom_openai_base_url
or os.environ.get("CUSTOM_OPENAI_BASE_URL", ""),
)
config.auxiliary_provider = aux_provider
if (
aux_provider == config.provider
and _provider_connection_configured(config, aux_provider)
):
if aux_provider == "ollama":
aux_ollama_detected_models = ollama_detected_models
_print_step_skipped(
"Co-pilot credentials",
"reusing main provider settings",
)
else:
try:
aux_ollama_detected_models = (
_configure_provider_connection(
config,
aux_provider,
strict=False,
skip_validation=skip_validation,
)
)
except GoBack:
for field_name in vars(loop_snapshot):
setattr(
config,
field_name,
getattr(loop_snapshot, field_name),
)
aux_ollama_detected_models = []
console.print(
" [dim]↩ Returning to co-pilot provider "
"selection.[/dim]"
)
continue
break
elif aux_provider == "custom-anthropic":
config.custom_anthropic_base_url = _step_base_url(
config,
current_value=config.custom_anthropic_base_url
or os.environ.get("CUSTOM_ANTHROPIC_BASE_URL", ""),
)
elif aux_provider == "minimax":
config.minimax_base_url = _step_minimax_region(config)
if aux_provider != "ollama":
aux_key_attr = _PROVIDER_KEY_ATTR.get(
aux_provider, "openai_api_key"
)
new_aux_key = _step_provider_api_key(
config, aux_provider, skip_validation
)
if new_aux_key is not None:
setattr(config, aux_key_attr, new_aux_key)
config.auxiliary_model = _step_model(
config,
aux_provider,
ollama_detected_models=aux_ollama_detected_models,
label="co-pilot",
default_value=config.auxiliary_model,
)
@@ -801,7 +725,6 @@ def run_onboard(
# Skip: single driver — clear any prior auxiliary config.
config.auxiliary_provider = ""
config.auxiliary_model = ""
_reconcile_oauth_modes(config)
_autosave(config)
if "tavily" in sections_to_run:
+2 -22
View File
@@ -106,23 +106,11 @@ def _normalize_hhmm(value: Any) -> str | None:
def get_config_dir() -> Path:
"""Get the configuration directory path.
Priority:
1. EVOSCIENTIST_CONFIG_DIR
2. EVOSCIENTIST_HOME/config
3. XDG_CONFIG_HOME/evoscientist
4. ~/.config/evoscientist
Uses XDG_CONFIG_HOME if set, otherwise ~/.config/evoscientist/
"""
configured = os.environ.get("EVOSCIENTIST_CONFIG_DIR")
if configured:
return Path(configured).expanduser().resolve()
home = os.environ.get("EVOSCIENTIST_HOME")
if home:
return Path(home).expanduser().resolve() / "config"
xdg_config = os.environ.get("XDG_CONFIG_HOME")
if xdg_config:
return Path(xdg_config).expanduser() / "evoscientist"
return Path(xdg_config) / "evoscientist"
return Path.home() / ".config" / "evoscientist"
@@ -560,10 +548,6 @@ def save_config(config: EvoScientistConfig) -> None:
"""
config_path = get_config_path()
config_path.parent.mkdir(parents=True, exist_ok=True)
try:
config_path.parent.chmod(0o700)
except OSError:
pass
data = _config_to_dict(config)
@@ -576,10 +560,6 @@ def save_config(config: EvoScientistConfig) -> None:
sort_keys=False,
allow_unicode=True,
)
try:
config_path.chmod(0o600)
except OSError:
pass
def reset_config() -> None:
+9 -2
View File
@@ -41,9 +41,15 @@ from ..stream.console import console
# Front-end npm package + spec. ``@latest`` → always the newest published UI.
_WEBUI_PACKAGE = "@evoscientist/webui@latest"
_WEBUI_PACKAGE_ENV = "EVOSCIENTIST_WEBUI_PACKAGE"
_DEFAULT_WEBUI_PORT = 4716
def _resolve_webui_package() -> str:
"""Return the npm package spec to launch for the WebUI front-end."""
return os.getenv(_WEBUI_PACKAGE_ENV) or _WEBUI_PACKAGE
def run_webui(config: Any, workspace_dir: str | None = None) -> None:
"""Start the deploy-style backend + the WebUI front-end, then block.
@@ -203,6 +209,7 @@ def run_webui(config: Any, workspace_dir: str | None = None) -> None:
"PORT": str(webui_port),
}
)
webui_package = _resolve_webui_package()
console.print(
Panel(
Text.from_markup(
@@ -211,7 +218,7 @@ def run_webui(config: Any, workspace_dir: str | None = None) -> None:
f"[bold]WebUI:[/bold] http://localhost:{webui_port} "
f"[dim](opens in your browser)[/dim]\n"
f"[bold]Logs:[/bold] {_shorten(str(RUNTIME.log_file))}\n\n"
f"[dim]Fetching {_WEBUI_PACKAGE} via npx (first run may take a "
f"[dim]Fetching {webui_package} via npx (first run may take a "
f"moment)… Press Ctrl+C to stop.[/dim]"
),
title="[bold green]✓ EvoScientist WebUI[/bold green]",
@@ -228,7 +235,7 @@ def run_webui(config: Any, workspace_dir: str | None = None) -> None:
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
try:
webui_proc = subprocess.Popen(
[npx, "--yes", _WEBUI_PACKAGE, "--port", str(webui_port)],
[npx, "--yes", webui_package, "--port", str(webui_port)],
**popen_kwargs,
)
except Exception as exc:
+281
View File
@@ -23,7 +23,17 @@ memory.
from __future__ import annotations
import asyncio
import json
import logging
import os
import sqlite3
from pathlib import Path
from typing import Any
import aiosqlite
import httpx
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse
@@ -31,6 +41,15 @@ from starlette.routing import Route
from EvoScientist.config import get_effective_config
from EvoScientist.llm.models import list_model_picker_entries
from EvoScientist.sessions import (
MAIN_THREAD_FILTER_PARAMS,
MAIN_THREAD_FILTER_SQL,
_load_checkpoint_messages,
_table_exists,
_to_short_path,
)
_logger = logging.getLogger(__name__)
async def get_models(_request: Request) -> JSONResponse:
@@ -75,8 +94,270 @@ async def get_models(_request: Request) -> JSONResponse:
)
def _message_type(message: Any) -> str | None:
if isinstance(message, dict):
role = message.get("role")
if role == "assistant":
return "ai"
if role == "user":
return "human"
value = message.get("type")
return str(value) if value is not None else None
value = getattr(message, "type", None)
return str(value) if value is not None else None
def _message_content(message: Any) -> Any:
if isinstance(message, dict):
return message.get("content")
return getattr(message, "content", None)
def _extract_text_content(content: Any) -> str:
if isinstance(content, str):
return content
if not isinstance(content, list):
return ""
parts: list[str] = []
for block in content:
if isinstance(block, str):
parts.append(block)
continue
if not isinstance(block, dict):
continue
block_type = block.get("type")
if block_type not in {"text", "output_text"}:
continue
text = block.get("text") or block.get("content")
if isinstance(text, str):
parts.append(text)
return "\n\n".join(part for part in parts if part)
def _is_tool_selection_payload(raw: str) -> bool:
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
return False
return (
isinstance(parsed, dict)
and set(parsed) == {"tools"}
and isinstance(parsed["tools"], list)
and all(isinstance(tool, str) for tool in parsed["tools"])
)
def _split_json_objects(raw: str) -> list[str] | None:
objects: list[str] = []
depth = 0
start = -1
in_string = False
escaping = False
for i, char in enumerate(raw):
if in_string:
if escaping:
escaping = False
elif char == "\\":
escaping = True
elif char == '"':
in_string = False
continue
if char == '"':
if depth == 0:
return None
in_string = True
continue
if char == "{":
if depth == 0:
start = i
depth += 1
continue
if char == "}":
depth -= 1
if depth < 0 or start < 0:
return None
if depth == 0:
objects.append(raw[start : i + 1])
start = -1
continue
if depth == 0 and not char.isspace():
return None
if depth != 0 or in_string or not objects:
return None
return objects
def _is_tool_selection_text(text: str) -> bool:
stripped = text.strip()
if not stripped or '"tools"' not in stripped:
return False
if _is_tool_selection_payload(stripped):
return True
objects = _split_json_objects(stripped)
return objects is not None and all(_is_tool_selection_payload(obj) for obj in objects)
def _extract_final_answer(messages: list[Any]) -> str:
"""Return displayable text from the latest AI message in *messages*."""
for message in reversed(messages):
if _message_type(message) != "ai":
continue
content = _extract_text_content(_message_content(message)).strip()
if content and not _is_tool_selection_text(content):
return content
return ""
def _sessions_db_path_for_http() -> Path:
data_dir = os.getenv("EVOSCIENTIST_DATA_DIR")
base = Path(data_dir).expanduser() if data_dir else Path.home() / ".evoscientist"
return Path(_to_short_path(str(base))) / "sessions.db"
async def _get_thread_metadata_for_http(thread_id: str) -> dict | None:
try:
async with aiosqlite.connect(
str(_sessions_db_path_for_http()), timeout=30.0
) as conn:
if not await _table_exists(conn, "checkpoints"):
return None
query = f"""
SELECT json_extract(metadata, '$.workspace_dir') as workspace_dir,
json_extract(metadata, '$.model') as model,
json_extract(metadata, '$.updated_at') as updated_at
FROM checkpoints
WHERE thread_id = ?
AND {MAIN_THREAD_FILTER_SQL}
ORDER BY checkpoint_id DESC
LIMIT 1
"""
async with conn.execute(
query, (thread_id, *MAIN_THREAD_FILTER_PARAMS)
) as cur:
row = await cur.fetchone()
except (OSError, sqlite3.Error):
return None
if not row:
return None
return {
"workspace_dir": row[0],
"model": row[1],
"updated_at": row[2],
}
async def _get_thread_messages_for_http(thread_id: str) -> list:
try:
async with aiosqlite.connect(
str(_sessions_db_path_for_http()), timeout=30.0
) as conn:
if not await _table_exists(conn, "checkpoints"):
return []
check = f"""
SELECT 1 FROM checkpoints
WHERE thread_id = ? AND {MAIN_THREAD_FILTER_SQL}
LIMIT 1
"""
async with conn.execute(
check, (thread_id, *MAIN_THREAD_FILTER_PARAMS)
) as cur:
if not await cur.fetchone():
return []
serde = JsonPlusSerializer()
saver = AsyncSqliteSaver(conn, serde=serde)
return await _load_checkpoint_messages(saver, thread_id)
except (OSError, sqlite3.Error):
return []
async def _read_thread_runtime_state(request: Request, thread_id: str) -> dict[str, Any]:
"""Read thread status from the co-hosted langgraph-api endpoints."""
base_url = f"{request.url.scheme}://{request.url.netloc}"
timeout = httpx.Timeout(2.0, connect=0.5)
async with httpx.AsyncClient(base_url=base_url, timeout=timeout) as client:
thread_resp = await client.get(f"/threads/{thread_id}")
if thread_resp.status_code == 404:
return {"found": False}
thread_resp.raise_for_status()
thread = thread_resp.json()
state: dict[str, Any] = {}
state_resp = await client.get(f"/threads/{thread_id}/state")
if state_resp.status_code == 404:
return {"found": False}
if state_resp.status_code < 400:
state = state_resp.json()
next_nodes = state.get("next")
is_terminal_checkpoint = isinstance(next_nodes, (list, tuple)) and not next_nodes
status = thread.get("status")
complete = status == "idle" or is_terminal_checkpoint
completed_at = None
if complete:
completed_at = (
thread.get("state_updated_at")
or thread.get("updated_at")
or (state.get("metadata") or {}).get("updated_at")
)
return {
"found": True,
"complete": complete,
"completed_at": completed_at,
}
async def get_final_answer(request: Request) -> JSONResponse:
"""Return the latest checkpointed assistant answer for a WebUI thread.
This is a recovery surface for the WebUI stream consumer: when browser-side
SSE is interrupted but the langgraph run continues server-side, the final
answer is already persisted in ``sessions.db``. The route centralizes the
non-trivial "latest AIMessage text only" extraction so the browser does not
render reasoning blocks, tool calls, or tool-selection JSON fragments.
"""
thread_id = request.path_params["thread_id"]
metadata = await _get_thread_metadata_for_http(thread_id)
if metadata is None:
return JSONResponse({"error": "thread not found"}, status_code=404)
messages = await _get_thread_messages_for_http(thread_id)
content = _extract_final_answer(messages)
runtime: dict[str, Any] = {"found": True, "complete": False, "completed_at": None}
try:
runtime = await _read_thread_runtime_state(request, thread_id)
except Exception as exc:
_logger.debug(
"Could not read langgraph runtime state for thread %s: %s",
thread_id,
exc,
)
if runtime.get("found") is False:
return JSONResponse({"error": "thread not found"}, status_code=404)
completed_at = runtime.get("completed_at")
if completed_at is None and runtime.get("complete"):
completed_at = metadata.get("updated_at")
return JSONResponse(
{
"content": content,
"completed_at": completed_at,
"complete": bool(runtime.get("complete")),
}
)
app = Starlette(
routes=[
Route("/api/models", get_models, methods=["GET"]),
Route(
"/api/threads/{thread_id}/final-answer",
get_final_answer,
methods=["GET"],
),
]
)
+5 -11
View File
@@ -306,19 +306,14 @@ def is_async_subagents_available() -> bool:
def _langgraph_exe() -> str | None:
"""Return the path to the langgraph CLI binary, or None if not found."""
import sys
executable_dir = os.path.dirname(sys.executable)
candidate_names = (
["langgraph.exe", "langgraph"] if os.name == "nt" else ["langgraph"]
)
for candidate_name in candidate_names:
candidate = os.path.join(executable_dir, candidate_name)
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
return candidate
found = shutil.which("langgraph")
if found:
return found
import sys as _sys
candidate = os.path.join(os.path.dirname(_sys.executable), "langgraph")
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
return candidate
return None
@@ -714,7 +709,6 @@ def start_langgraph_dev(
sub_env["EVOSCIENTIST_DEPLOY_MODE"] = "full" if deploy_mode else "stripped"
try:
logger.info("Starting langgraph dev with CLI: %s", exe)
proc = subprocess.Popen(
[
exe,
+2 -6
View File
@@ -20,9 +20,9 @@ _KNOWN_MODEL_CONTEXT_WINDOWS: dict[str, int] = {
# Qwen 3.7 closed-source tiers — Max flagship and Plus (1M).
"qwen3.7-max": 1_000_000,
"qwen3.7-plus": 1_000_000,
# xAI Grok — per-model windows (build-0.1: 256K, 4.5: 500K).
# xAI Grok — per-model windows (build-0.1: 256K, 4.3: 1M).
"grok-build-0.1": 256_000,
"grok-4.5": 500_000,
"grok-4.3": 1_000_000,
# Claude Haiku 4.5 — exception to the ``claude-`` family (200K, not 1M).
"claude-haiku-4-5": 200_000,
# MiniMax M3 — 1M context (M2.x variants stay at provider default ~204K).
@@ -32,8 +32,6 @@ _KNOWN_MODEL_CONTEXT_WINDOWS: dict[str, int] = {
# Zhipu GLM-5.2 — 1M context, an exception to the ``glm-5`` family (203K).
# Matches OpenRouter ``z-ai/glm-5.2`` via split('/')[-1].
"glm-5.2": 1_000_000,
# Tencent Hunyuan HY3 — 262K context (OpenRouter ``tencent/hy3``).
"hy3": 262_000,
}
# Family-level fallbacks: tried only after exact-name lookup misses.
@@ -42,8 +40,6 @@ _KNOWN_MODEL_CONTEXT_WINDOWS: dict[str, int] = {
_KNOWN_MODEL_FAMILIES: list[tuple[str, int]] = [
# All Claude — 1M via the ``context-1m-2025-08-07`` beta header.
("claude-", 1_000_000),
# OpenAI GPT-5.6 family — sol, terra, luna variants
("gpt-5.6", 1_050_000),
# OpenAI GPT-5.5 family — base, pro, future variants
("gpt-5.5", 1_050_000),
# Google Gemini 3.x family — flash, flash-lite, pro (1.05M). Excludes 2.5.
+29 -114
View File
@@ -68,11 +68,6 @@ _THINKING_CAPABLE_PROVIDERS: set[str] = {"minimax"}
_TRUTHY_ENV_VALUES = {"1", "true", "yes", "on"}
_FALSEY_ENV_VALUES = {"0", "false", "no", "off"}
# Legacy/provider-specific options that are not accepted by the installed
# LangChain chat model constructors. Leaving them at the top level makes
# LangChain move them into model_kwargs and can later leak them into SDK calls.
_UNSUPPORTED_CHAT_MODEL_KWARGS = frozenset({"sanitize_openai_sdk_headers"})
# Model registry: list of (short_name, model_id, provider)
# Allows same short_name across different providers.
_MODEL_ENTRIES: list[tuple[str, str, str]] = [
@@ -94,9 +89,6 @@ _MODEL_ENTRIES: list[tuple[str, str, str]] = [
("claude-sonnet-4-6", "claude-sonnet-4-6", "anthropic"),
("claude-haiku-4-5", "claude-haiku-4-5", "anthropic"),
# OpenAI
("gpt-5.6-sol", "gpt-5.6-sol", "openai"),
("gpt-5.6-terra", "gpt-5.6-terra", "openai"),
("gpt-5.6-luna", "gpt-5.6-luna", "openai"),
("gpt-5.5-pro", "gpt-5.5-pro", "openai"),
("gpt-5.5", "gpt-5.5", "openai"),
("gpt-5.4", "gpt-5.4", "openai"),
@@ -153,9 +145,6 @@ _MODEL_ENTRIES: list[tuple[str, str, str]] = [
("claude-opus-4.8-fast", "anthropic/claude-opus-4.8-fast", "openrouter"),
("claude-sonnet-5", "anthropic/claude-sonnet-5", "openrouter"),
("claude-sonnet-4.6", "anthropic/claude-sonnet-4.6", "openrouter"),
("gpt-5.6-sol", "openai/gpt-5.6-sol", "openrouter"),
("gpt-5.6-terra", "openai/gpt-5.6-terra", "openrouter"),
("gpt-5.6-luna", "openai/gpt-5.6-luna", "openrouter"),
("gpt-5.5-pro", "openai/gpt-5.5-pro", "openrouter"),
("gpt-5.5", "openai/gpt-5.5", "openrouter"),
("gpt-5.4", "openai/gpt-5.4", "openrouter"),
@@ -170,8 +159,7 @@ _MODEL_ENTRIES: list[tuple[str, str, str]] = [
("mimo-v2.5-pro", "xiaomi/mimo-v2.5-pro", "openrouter"),
("mimo-v2.5", "xiaomi/mimo-v2.5", "openrouter"),
("grok-build-0.1", "x-ai/grok-build-0.1", "openrouter"),
("grok-4.5", "x-ai/grok-4.5", "openrouter"),
("hy3", "tencent/hy3", "openrouter"),
("grok-4.3", "x-ai/grok-4.3", "openrouter"),
("qwen3.7-max", "qwen/qwen3.7-max", "openrouter"),
("qwen3.7-plus", "qwen/qwen3.7-plus", "openrouter"),
("qwen3.6-flash", "qwen/qwen3.6-flash", "openrouter"),
@@ -269,15 +257,6 @@ def _env_flag_disabled(name: str) -> bool:
return value is not None and value.strip().lower() in _FALSEY_ENV_VALUES
def _drop_unsupported_chat_model_kwargs(kwargs: dict[str, Any]) -> None:
for key in _UNSUPPORTED_CHAT_MODEL_KWARGS:
kwargs.pop(key, None)
model_kwargs = kwargs.get("model_kwargs")
if isinstance(model_kwargs, dict):
for key in _UNSUPPORTED_CHAT_MODEL_KWARGS:
model_kwargs.pop(key, None)
def _supports_openrouter_anthropic_prompt_cache(provider: str, model_id: str) -> bool:
"""Return whether EvoScientist should declare OpenRouter Claude caching."""
return provider == "openrouter" and model_id.startswith(
@@ -335,16 +314,8 @@ def _apply_auto_config(
Mutates *kwargs* in place. Only sets keys that the caller hasn't already
provided, so explicit user settings are never overridden.
"""
disable_reasoning = bool(kwargs.pop("_disable_reasoning", False))
disable_thinking = bool(kwargs.pop("_disable_thinking", False))
if disable_reasoning:
kwargs.pop("reasoning", None)
kwargs.pop("include_thoughts", None)
if disable_thinking:
kwargs.pop("thinking", None)
# Anthropic: extended thinking
if provider == "anthropic" and not disable_thinking and "thinking" not in kwargs:
if provider == "anthropic" and "thinking" not in kwargs:
_supports_thinking = original_provider in _THINKING_CAPABLE_PROVIDERS
# Detect local proxy (e.g. ccproxy): thinking blocks in conversation
# history cause 422 errors because the proxy doesn't accept 'thinking'
@@ -363,13 +334,8 @@ def _apply_auto_config(
kwargs["thinking"] = {"type": "enabled", "budget_tokens": 10000}
# OpenAI (native, not third-party routed): reasoning
if (
provider == "openai"
and not is_third_party
and not disable_reasoning
and "reasoning" not in kwargs
):
if _is_ccproxy_codex(kwargs.get("base_url"), kwargs.get("api_key")):
if provider == "openai" and not is_third_party and "reasoning" not in kwargs:
if _is_ccproxy_codex():
# ccproxy uses Chat Completions which doesn't support reasoning.
pass
else:
@@ -381,11 +347,11 @@ def _apply_auto_config(
kwargs["reasoning"] = {"effort": _eff, "summary": "auto"}
# Google GenAI: surface thinking traces
if provider == "google-genai" and not disable_reasoning:
if provider == "google-genai":
kwargs.setdefault("include_thoughts", True)
# Ollama: separate reasoning content from response for thinking models
if provider == "ollama" and not disable_reasoning and "reasoning" not in kwargs:
if provider == "ollama" and "reasoning" not in kwargs:
kwargs["reasoning"] = True
@@ -412,46 +378,7 @@ def get_chat_model(
>>> model = get_chat_model("gpt-4o") # OpenAI model
>>> model = get_chat_model("claude-3-opus-20240229", provider="anthropic") # Full ID
"""
skip_runtime_resolver = bool(kwargs.pop("_skip_runtime_model_resolver", False))
runtime_provider_name: str | None = None
runtime_supports_reasoning: bool | None = None
runtime_resolved = None
if not skip_runtime_resolver:
from EvoScientist.runtime_integrations import resolve_runtime_model
runtime_resolved = resolve_runtime_model(model, provider)
if runtime_resolved is not None:
resolved_params = dict(getattr(runtime_resolved, "params", {}) or {})
extra_body = resolved_params.pop("_extra_body", None)
default_headers = resolved_params.pop("_default_headers", None)
if extra_body:
resolved_params["extra_body"] = extra_body
if default_headers:
resolved_params["default_headers"] = default_headers
resolved_params.update(kwargs)
kwargs = resolved_params
resolved_api_key = str(getattr(runtime_resolved, "api_key", "") or "")
resolved_base_url = str(getattr(runtime_resolved, "base_url", "") or "")
if resolved_api_key:
kwargs.setdefault("api_key", resolved_api_key)
if resolved_base_url:
kwargs.setdefault("base_url", resolved_base_url.rstrip("/"))
runtime_provider_name = str(
getattr(runtime_resolved, "provider_name", "") or ""
)
runtime_supports_reasoning = bool(
getattr(runtime_resolved, "supports_reasoning", False)
)
if not runtime_supports_reasoning:
kwargs.setdefault("_disable_reasoning", True)
kwargs.setdefault("_disable_thinking", True)
model = str(runtime_resolved.model_id)
provider = str(runtime_resolved.protocol)
else:
model = model or DEFAULT_MODEL
model = model or DEFAULT_MODEL
# Look up short name in registry (provider-aware)
model_id = None
@@ -486,35 +413,22 @@ def get_chat_model(
_is_third_party = (
provider in _OPENAI_ROUTED_PROVIDERS or provider in _ANTHROPIC_ROUTED_PROVIDERS
)
if runtime_provider_name and runtime_provider_name != provider:
_is_third_party = True
if (
runtime_resolved is not None
and provider == "openai"
and resolved_base_url
and "api.openai.com" not in resolved_base_url.lower()
):
_is_third_party = True
_is_openai_proxy = False
_original_provider: str | None = (
runtime_provider_name if runtime_provider_name != provider else None
)
_original_provider: str | None = None
if provider == "anthropic":
base_url = os.environ.get("ANTHROPIC_BASE_URL", "")
if base_url:
kwargs.setdefault("base_url", base_url)
kwargs["base_url"] = base_url
api_key = os.environ.get("ANTHROPIC_API_KEY", "")
if api_key:
kwargs.setdefault("api_key", api_key)
kwargs["api_key"] = api_key
# Native OpenAI base_url override (e.g. ccproxy Codex at localhost:8000/codex/v1)
elif provider == "openai":
base_url = os.environ.get("OPENAI_BASE_URL", "")
if base_url:
kwargs.setdefault("base_url", base_url)
_is_openai_proxy = _is_ccproxy_codex(
kwargs.get("base_url"), kwargs.get("api_key")
)
kwargs["base_url"] = base_url
_is_openai_proxy = _is_ccproxy_codex()
if _is_openai_proxy:
# Use Responses API for ccproxy: bypasses the format chain
# converter (Chat→Responses→Chat) which returns 502 on
@@ -529,7 +443,7 @@ def get_chat_model(
kwargs.pop("streaming", None) # remove if set elsewhere
api_key = os.environ.get("OPENAI_API_KEY", "")
if api_key:
kwargs.setdefault("api_key", api_key)
kwargs["api_key"] = api_key
# OpenAI-routed providers → route through OpenAI provider with base_url
elif provider in _OPENAI_ROUTED_PROVIDERS:
@@ -547,10 +461,10 @@ def get_chat_model(
else:
base_url = base_url_default
if base_url:
kwargs.setdefault("base_url", base_url)
kwargs["base_url"] = base_url
api_key = os.environ.get(api_key_env, "")
if api_key:
kwargs.setdefault("api_key", api_key)
kwargs["api_key"] = api_key
# SiliconFlow: disable thinking — LangChain drops reasoning_content
# from history, causing error 20015 on multi-turn requests.
if provider == "siliconflow":
@@ -560,6 +474,15 @@ def get_chat_model(
# Even native thinking models like kimi-k2-thinking operate in non-thinking mode.
if provider == "moonshot":
kwargs.setdefault("extra_body", {})["thinking"] = {"type": "disabled"}
# custom-openai: some Codex-orientated reseller gateways (e.g.
# hunnuapi.top) blacklist the openai SDK's default
# "OpenAI/Python" User-Agent at the app layer and return
# 403 "Your request was blocked." to any langchain-openai client.
# Spoof a Codex-CLI UA so requests go through.
if provider == "custom-openai":
kwargs.setdefault("default_headers", {}).setdefault(
"User-Agent", "codex_cli_rs/0.0.0"
)
provider = "openai"
# OpenRouter → native ChatOpenRouter via init_chat_model.
@@ -567,7 +490,7 @@ def get_chat_model(
_is_third_party = True
api_key = os.environ.get("OPENROUTER_API_KEY", "")
if api_key:
kwargs.setdefault("api_key", api_key)
kwargs["api_key"] = api_key
# Reasoning via `effort` + `summary: "auto"` so a readable reasoning
# summary is returned for display. OpenAI-Responses also emits encrypted
# reasoning items (`rs_*` id) that can't be replayed on multi-turn
@@ -596,10 +519,10 @@ def get_chat_model(
else:
base_url = base_url_default
if base_url:
kwargs.setdefault("base_url", base_url)
kwargs["base_url"] = base_url
api_key = os.environ.get(api_key_env, "")
if api_key:
kwargs.setdefault("api_key", api_key)
kwargs["api_key"] = api_key
# Kimi Coding Plan requires claude-code User-Agent header
if provider == "kimi-coding":
kwargs.setdefault("default_headers", {})["User-Agent"] = "claude-code/0.1.0"
@@ -608,9 +531,8 @@ def get_chat_model(
elif provider == "ollama":
base_url = os.environ.get("OLLAMA_BASE_URL", "")
if base_url:
kwargs.setdefault("base_url", base_url)
kwargs["base_url"] = base_url
_drop_unsupported_chat_model_kwargs(kwargs)
_apply_auto_config(provider, model_id, _is_third_party, kwargs, _original_provider)
_apply_openrouter_anthropic_prompt_cache(provider, model_id, kwargs)
@@ -627,14 +549,7 @@ def get_chat_model(
elif _responses_api_setting == "true":
kwargs["use_responses_api"] = True
anthropic_auth_token = None
if provider == "anthropic" and kwargs.get("api_key"):
anthropic_auth_token = os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
try:
chat_model = init_chat_model(model=model_id, model_provider=provider, **kwargs)
finally:
if anthropic_auth_token is not None:
os.environ["ANTHROPIC_AUTH_TOKEN"] = anthropic_auth_token
chat_model = init_chat_model(model=model_id, model_provider=provider, **kwargs)
# Flatten list content to strings for strict OpenAI-compatible providers
# (DeepSeek, SiliconFlow, OpenRouter, custom-openai, etc.) and
+3 -86
View File
@@ -25,7 +25,6 @@ Utilities:
from __future__ import annotations
import hashlib
import os
from typing import Any
@@ -179,20 +178,15 @@ _patch_ccproxy_codex_compat()
# ---------------------------------------------------------------------------
# Utility: detect ccproxy's Codex adapter (as opposed to generic localhost).
# ---------------------------------------------------------------------------
def _is_ccproxy_codex(
base_url: str | None = None,
api_key: str | None = None,
) -> bool:
def _is_ccproxy_codex() -> bool:
"""Return True if the OpenAI endpoint is ccproxy's Codex adapter.
Checks for the ccproxy-specific markers set by ``setup_codex_env()``
in ``ccproxy_manager.py``: the sentinel API key and the ``/codex/v1``
path. Plain localhost endpoints (vLLM, Ollama, etc.) are not affected.
"""
if base_url is None:
base_url = os.environ.get("OPENAI_BASE_URL", "")
if api_key is None:
api_key = os.environ.get("OPENAI_API_KEY", "")
base_url = os.environ.get("OPENAI_BASE_URL", "")
api_key = os.environ.get("OPENAI_API_KEY", "")
return (
("127.0.0.1" in base_url or "localhost" in base_url)
and api_key == "ccproxy-oauth"
@@ -273,82 +267,6 @@ def _flatten_message_content(content: Any) -> str | list[Any] | Any:
return "\n\n".join(parts) if parts else ""
def _stable_tool_call_id(message: Any, message_index: int, call_index: int) -> str:
seed = ":".join(
(
str(getattr(message, "id", "") or "message"),
str(message_index),
str(call_index),
)
)
return "call_" + hashlib.sha256(seed.encode("utf-8")).hexdigest()[:24]
def _ensure_openai_tool_call_ids(messages: list[Any]) -> list[Any]:
"""Copy messages and repair missing AI/ToolMessage call identifiers."""
import copy
from collections import deque
pending_call_ids: deque[str] = deque()
normalized: list[Any] = []
for message_index, message in enumerate(messages):
message_type = getattr(message, "type", None)
if message_type == "ai":
tool_calls = list(getattr(message, "tool_calls", None) or [])
if not tool_calls:
normalized.append(message)
continue
copied = copy.copy(message)
normalized_calls: list[dict[str, Any]] = []
for call_index, original_call in enumerate(tool_calls):
call = dict(original_call)
call_id = str(call.get("id") or "") or _stable_tool_call_id(
message, message_index, call_index
)
call["id"] = call_id
normalized_calls.append(call)
pending_call_ids.append(call_id)
copied.tool_calls = normalized_calls
if isinstance(copied.content, list):
call_index = 0
blocks: list[Any] = []
for original_block in copied.content:
if not isinstance(original_block, dict):
blocks.append(original_block)
continue
block = dict(original_block)
if block.get("type") in {"tool_call", "function_call"}:
if call_index < len(normalized_calls):
block["id"] = normalized_calls[call_index]["id"]
call_index += 1
blocks.append(block)
copied.content = blocks
normalized.append(copied)
continue
if message_type == "tool":
tool_call_id = str(getattr(message, "tool_call_id", "") or "")
if tool_call_id:
try:
pending_call_ids.remove(tool_call_id)
except ValueError:
pass
normalized.append(message)
continue
if pending_call_ids:
copied = copy.copy(message)
copied.tool_call_id = pending_call_ids.popleft()
normalized.append(copied)
continue
normalized.append(message)
return normalized
def _sanitize_messages(messages: list[Any], hoist_tool_media: bool = True) -> list[Any]:
"""Flatten list content for OpenAI-compatible APIs, preserving media.
@@ -364,7 +282,6 @@ def _sanitize_messages(messages: list[Any], hoist_tool_media: bool = True) -> li
from langchain_core.messages import HumanMessage
messages = _ensure_openai_tool_call_ids(messages)
out: list[Any] = []
pending_media: list[Any] = [] # media hoisted out of a run of tool messages
-302
View File
@@ -1,302 +0,0 @@
"""Shared logging configuration helpers."""
from __future__ import annotations
import logging
import os
import sys
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, TextIO
DEFAULT_LOG_RETENTION_DAYS = 30
DEFAULT_LOG_FORMAT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
DEFAULT_LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
MANAGED_HANDLER_ATTR = "_evoscientist_managed_handler"
def resolve_log_level(level: int | str | None, default: int = logging.INFO) -> int:
"""Resolve a logging level from config or environment input."""
if isinstance(level, int):
return level
raw = str(level or "").strip()
if not raw:
return default
if raw.isdigit():
return int(raw)
normalized = raw.upper()
if normalized == "WARN":
normalized = "WARNING"
resolved = logging.getLevelNamesMapping().get(normalized)
return resolved if isinstance(resolved, int) else default
def _mark_managed(handler: logging.Handler, kind: str) -> logging.Handler:
setattr(handler, MANAGED_HANDLER_ATTR, kind)
return handler
def _managed_kind(handler: logging.Handler) -> str | None:
kind = getattr(handler, MANAGED_HANDLER_ATTR, None)
return kind if isinstance(kind, str) else None
def remove_managed_handlers(
logger: logging.Logger | None = None,
*,
kinds: set[str] | None = None,
) -> None:
"""Remove handlers installed by this module without touching external ones."""
target = logger or logging.getLogger()
for handler in target.handlers[:]:
kind = _managed_kind(handler)
if kind and (kinds is None or kind in kinds):
target.removeHandler(handler)
handler.close()
def _standard_formatter() -> logging.Formatter:
return logging.Formatter(DEFAULT_LOG_FORMAT, datefmt=DEFAULT_LOG_DATE_FORMAT)
class DailyLogFileHandler(logging.FileHandler):
"""File handler that writes the active log to a date-based filename."""
def __init__(
self,
log_dir: str | Path,
*,
prefix: str = "evoscientist",
retention_days: int = DEFAULT_LOG_RETENTION_DAYS,
encoding: str = "utf-8",
utc: bool = False,
) -> None:
self.log_dir = Path(log_dir).expanduser()
self.prefix = prefix
self.retention_days = max(1, retention_days)
self.utc = utc
self.log_dir.mkdir(parents=True, exist_ok=True)
super().__init__(self._dated_log_path(), encoding=encoding, delay=True)
@property
def active_log_path(self) -> Path:
"""Return the active log path for the current date."""
return self._dated_log_path()
def _dated_log_path(self) -> Path:
now = datetime.now(UTC if self.utc else None)
return self.log_dir / f"{self.prefix}-{now:%Y-%m-%d}.log"
def emit(self, record: logging.LogRecord) -> None:
try:
expected = str(self.active_log_path)
if self.baseFilename != expected:
if self.stream:
self.stream.close()
self.stream = None
self.baseFilename = expected
self._delete_expired_logs()
super().emit(record)
except OSError:
self.handleError(record)
def getFilesToDelete(self) -> list[str]:
candidates = sorted(self.log_dir.glob(f"{self.prefix}-????-??-??.log"))
if len(candidates) <= self.retention_days:
return []
return [str(path) for path in candidates[: -self.retention_days]]
def _delete_expired_logs(self) -> None:
for path in self.getFilesToDelete():
try:
os.remove(path)
except OSError:
pass
def default_log_dir() -> Path:
"""Return the default runtime log directory."""
env_dir = os.environ.get("EVOSCIENTIST_LOG_DIR")
if env_dir:
return Path(env_dir).expanduser()
from EvoScientist.paths import DATA_DIR
return DATA_DIR / "logs"
def configure_daily_file_logging(
logger: logging.Logger | None = None,
*,
log_dir: str | Path | None = None,
prefix: str = "evoscientist",
level: int | str = logging.INFO,
retention_days: int = DEFAULT_LOG_RETENTION_DAYS,
) -> DailyLogFileHandler:
"""Attach a daily file handler, replacing older matching handlers."""
target = logger or logging.getLogger()
resolved_level = resolve_log_level(level, default=logging.INFO)
retention_days = max(1, int(retention_days))
resolved_dir = Path(log_dir).expanduser() if log_dir else default_log_dir()
for handler in target.handlers[:]:
if (
isinstance(handler, DailyLogFileHandler)
and handler.prefix == prefix
and handler.log_dir == resolved_dir
):
target.removeHandler(handler)
handler.close()
handler = DailyLogFileHandler(
resolved_dir,
prefix=prefix,
retention_days=retention_days,
)
_mark_managed(handler, "file")
handler.setLevel(resolved_level)
handler.setFormatter(_standard_formatter())
target.addHandler(handler)
if target.level == logging.NOTSET or target.level > resolved_level:
target.setLevel(resolved_level)
return handler
def configure_console_logging(
logger: logging.Logger | None = None,
*,
level: int | str | None = logging.INFO,
stream: TextIO | None = None,
replace: bool = True,
) -> logging.StreamHandler:
"""Attach a standard console handler for non-interactive entry points."""
target = logger or logging.getLogger()
resolved_level = resolve_log_level(level, default=logging.INFO)
if replace:
remove_managed_handlers(target, kinds={"console", "rich"})
handler = logging.StreamHandler(stream or sys.stderr)
_mark_managed(handler, "console")
handler.setLevel(resolved_level)
handler.setFormatter(_standard_formatter())
target.addHandler(handler)
target.setLevel(resolved_level)
return handler
def configure_rich_console_logging(
logger: logging.Logger | None = None,
*,
level: int | str | None = logging.INFO,
console: Any = None,
replace: bool = True,
dim_warnings: bool = False,
show_time: bool | None = None,
show_path: bool | None = None,
show_level: bool | None = None,
) -> logging.Handler:
"""Attach a Rich console handler for interactive CLI output."""
from rich.logging import RichHandler
from rich.markup import escape
target = logger or logging.getLogger()
resolved_level = resolve_log_level(level, default=logging.INFO)
verbose = resolved_level <= logging.DEBUG
if replace:
remove_managed_handlers(target, kinds={"console", "rich"})
class DimWarningHandler(RichHandler):
def emit(self, record: logging.LogRecord) -> None:
if dim_warnings and record.levelno == logging.WARNING and console is not None:
console.print(
"[dim yellow]\u26a0\ufe0f Warning:[/dim yellow] "
f"[dim]{escape(record.getMessage())}[/dim]"
)
return
super().emit(record)
handler = DimWarningHandler(
console=console,
show_time=verbose if show_time is None else show_time,
show_path=verbose if show_path is None else show_path,
show_level=verbose if show_level is None else show_level,
)
_mark_managed(handler, "rich")
handler.setLevel(resolved_level)
target.addHandler(handler)
target.setLevel(resolved_level)
return handler
def configure_logging(
logger: logging.Logger | None = None,
*,
level: int | str | None = logging.INFO,
log_dir: str | Path | None = None,
retention_days: int = DEFAULT_LOG_RETENTION_DAYS,
prefix: str = "evoscientist",
console: bool = True,
file: bool = True,
replace_managed: bool = True,
) -> list[logging.Handler]:
"""Configure standard EvoScientist console and daily file logging."""
target = logger or logging.getLogger()
resolved_level = resolve_log_level(level, default=logging.INFO)
if replace_managed:
remove_managed_handlers(target, kinds={"console", "rich", "file"})
handlers: list[logging.Handler] = []
if console:
handlers.append(
configure_console_logging(target, level=resolved_level, replace=False)
)
if file:
handlers.append(
configure_daily_file_logging(
target,
log_dir=log_dir,
prefix=prefix,
level=resolved_level,
retention_days=retention_days,
)
)
target.setLevel(resolved_level)
return handlers
def configure_logging_from_settings(
logger: logging.Logger | None = None,
*,
default_level: int = logging.INFO,
prefix: str = "evoscientist",
console: bool = True,
file: bool = True,
) -> list[logging.Handler]:
"""Configure logging from EvoScientist settings and environment overrides."""
level: int | str | None = os.environ.get("EVOSCIENTIST_LOG_LEVEL")
log_dir: str | Path | None = os.environ.get("EVOSCIENTIST_LOG_DIR") or None
retention_days = int(
os.environ.get("EVOSCIENTIST_LOG_RETENTION_DAYS", DEFAULT_LOG_RETENTION_DAYS)
)
try:
from EvoScientist.config import get_effective_config
cfg = get_effective_config()
level = level or getattr(cfg, "log_level", None)
log_dir = log_dir or getattr(cfg, "log_dir", None) or None
retention_days = int(
getattr(cfg, "log_retention_days", DEFAULT_LOG_RETENTION_DAYS)
)
except Exception:
level = level or default_level
return configure_logging(
logger,
level=resolve_log_level(level, default=default_level),
log_dir=log_dir,
retention_days=retention_days,
prefix=prefix,
console=console,
file=file,
)
-2
View File
@@ -10,7 +10,6 @@ from .client import (
build_mcp_add_kwargs,
build_mcp_edit_fields,
edit_mcp_server,
get_mcp_server_errors,
load_mcp_config,
load_mcp_tools,
parse_mcp_add_args,
@@ -39,7 +38,6 @@ __all__ = [
"find_server_by_name",
"get_all_tags",
"get_installed_names",
"get_mcp_server_errors",
"install_mcp_server",
"install_mcp_servers",
"load_mcp_config",
+1 -16
View File
@@ -114,10 +114,6 @@ _URL_TRANSPORTS = {"http", "streamable_http", "sse", "websocket"}
# still parallelizing the common 3–7 server case to completion.
_MAX_CONCURRENT_CONNECTIONS = 8
# Last connection error per configured server. This is process-local runtime
# diagnostics for the Web/CLI status surfaces, not persisted configuration.
_MCP_SERVER_ERRORS: dict[str, str] = {}
# Env vars forwarded to stdio MCP subprocesses on top of the MCP SDK's
# minimal default set (HOME/PATH/USER/…). Without this, servers behind
# a proxy or with a custom CA bundle silently fail with long timeouts.
@@ -768,9 +764,6 @@ async def _load_tools(
if not connections:
return {}
for stale_name in set(_MCP_SERVER_ERRORS) - set(connections):
_MCP_SERVER_ERRORS.pop(stale_name, None)
client = MultiServerMCPClient(connections) # type: ignore[invalid-argument-type]
def _report(event: str, name: str, detail: str = "") -> None:
@@ -794,13 +787,10 @@ async def _load_tools(
_report("start", name)
try:
tools = await client.get_tools(server_name=name)
_MCP_SERVER_ERRORS.pop(name, None)
logger.info("MCP server %r: loaded %d tool(s)", name, len(tools))
_report("success", name, str(len(tools)))
return name, tools
except Exception as exc:
detail = str(exc) or type(exc).__name__
_MCP_SERVER_ERRORS[name] = detail
# When the caller wired up ``on_progress`` they own the
# user-facing display; downgrade the logger so we don't
# double-print.
@@ -808,7 +798,7 @@ async def _load_tools(
logger.warning("MCP server %r: failed to load tools: %s", name, exc)
else:
logger.debug("MCP server %r: failed to load tools: %s", name, exc)
_report("error", name, detail)
_report("error", name, str(exc))
return name, []
# ``return_exceptions=False`` is fine because ``_fetch`` already
@@ -817,11 +807,6 @@ async def _load_tools(
return dict(results)
def get_mcp_server_errors() -> dict[str, str]:
"""Return a snapshot of the most recent per-server connection errors."""
return dict(_MCP_SERVER_ERRORS)
async def aload_mcp_tools(
config: dict[str, Any] | None = None,
*,
-63
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
import logging
import os
import shutil
from collections.abc import Iterator
from datetime import datetime
from pathlib import Path
@@ -216,65 +215,3 @@ def resolve_virtual_path(virtual_path: str) -> Path:
"""Resolve a virtual workspace path (e.g. /image.png) to a real filesystem path."""
vpath = virtual_path if virtual_path.startswith("/") else "/" + virtual_path
return (_active_workspace / vpath.lstrip("/")).resolve()
def evoscientist_root() -> Path:
"""Return the application root used by Gateway-managed runtime data."""
env_root = os.environ.get("EVOSCIENTIST_HOME")
if env_root:
return Path(env_root).expanduser().resolve()
return DATA_DIR.expanduser().resolve()
_EVOSCIENTIST_DATA_ROOT: Path | None = None
def _data_root() -> Path:
"""Return the root directory for isolated Web user workspaces."""
global _EVOSCIENTIST_DATA_ROOT
if _EVOSCIENTIST_DATA_ROOT is not None:
return _EVOSCIENTIST_DATA_ROOT
env_root = os.environ.get("EVOSCIENTIST_DATA_ROOT")
if env_root:
root = Path(env_root).expanduser().resolve()
else:
root = evoscientist_root() / "data"
_EVOSCIENTIST_DATA_ROOT = root
return root
def user_data_dir(user_id: str) -> Path:
"""Return and create the isolated data directory for a Web user."""
path = _data_root() / user_id
path.mkdir(parents=True, exist_ok=True)
return path
def iter_user_data_dirs() -> Iterator[Path]:
"""Yield existing Web user directories without creating the data root."""
root = _data_root()
if not root.exists():
return
for path in root.iterdir():
if path.is_dir():
yield path
def thread_data_dir(user_id: str, thread_id: str) -> Path:
"""Return and create a user's isolated thread workspace."""
path = user_data_dir(user_id) / thread_id
path.mkdir(parents=True, exist_ok=True)
return path
def global_data_dir(user_id: str) -> Path:
"""Return and create a user's directory shared across all threads."""
path = user_data_dir(user_id) / "__global__"
path.mkdir(parents=True, exist_ok=True)
return path
def uploads_dir() -> Path:
"""Return the Gateway upload staging directory."""
return evoscientist_root() / "uploads"
-116
View File
@@ -1,116 +0,0 @@
"""Optional runtime services supplied by an application embedding EvoScientist.
The CLI package must not import a concrete web gateway. Applications such as
Ai4Sci-Web can register their database, storage, metering, and media services
at process startup through this module.
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, replace
from datetime import date
from pathlib import Path
from typing import Any
AsyncProvider = Callable[[], Awaitable[Any]]
AsyncFileHandler = Callable[[Path], Awaitable[Any]]
AsyncUsageRecorder = Callable[[str, str], Awaitable[Any]]
ModelResolver = Callable[[str | None, str | None], Any | None]
class RuntimeIntegrationUnavailable(RuntimeError):
"""Raised when an optional host-provided service is not configured."""
@dataclass(frozen=True)
class RuntimeIntegrations:
app_connection_provider: AsyncProvider | None = None
session_connection_provider: AsyncProvider | None = None
session_dsn_provider: Callable[[], str | None] | None = None
current_date_provider: Callable[[], date] | None = None
user_storage_root_provider: Callable[[str], Path] | None = None
knowledge_file_handler: AsyncFileHandler | None = None
usage_recorder: AsyncUsageRecorder | None = None
image_backend_factory: Callable[[], Any] | None = None
model_resolver: ModelResolver | None = None
_integrations = RuntimeIntegrations()
def configure_runtime_integrations(**services: Any) -> RuntimeIntegrations:
"""Register host-provided services and return the resulting configuration."""
global _integrations
_integrations = replace(_integrations, **services)
return _integrations
def reset_runtime_integrations() -> None:
"""Clear all host-provided services, primarily for tests."""
global _integrations
_integrations = RuntimeIntegrations()
def has_session_connection_provider() -> bool:
return _integrations.session_connection_provider is not None
def get_session_dsn() -> str | None:
provider = _integrations.session_dsn_provider
return provider() if provider is not None else None
async def get_session_connection() -> Any:
provider = _integrations.session_connection_provider
if provider is None:
raise RuntimeIntegrationUnavailable(
"No session connection provider is configured"
)
return await provider()
async def get_app_connection() -> Any:
provider = _integrations.app_connection_provider
if provider is None:
raise RuntimeIntegrationUnavailable(
"No application connection provider is configured"
)
return await provider()
def current_date() -> date:
provider = _integrations.current_date_provider
return provider() if provider is not None else date.today()
def resolve_user_storage_root(user_id: str) -> Path | None:
provider = _integrations.user_storage_root_provider
return provider(user_id) if provider is not None else None
def resolve_runtime_model(model: str | None, provider: str | None = None) -> Any | None:
"""Resolve a host-managed model configuration when one is registered."""
resolver = _integrations.model_resolver
return resolver(model, provider) if resolver is not None else None
async def handle_knowledge_file(path: Path) -> None:
handler = _integrations.knowledge_file_handler
if handler is not None:
await handler(path)
async def record_service_usage(service: str, action: str) -> None:
recorder = _integrations.usage_recorder
if recorder is not None:
await recorder(service, action)
def get_image_backend() -> Any:
factory = _integrations.image_backend_factory
if factory is None:
raise RuntimeIntegrationUnavailable(
"Image generation is unavailable in this runtime. Configure an image backend first."
)
return factory()
-8
View File
@@ -8,12 +8,10 @@ import base64
import inspect
import mimetypes
import os
import warnings
from collections.abc import AsyncGenerator, AsyncIterator, Mapping
from dataclasses import dataclass
from typing import Any, TypeAlias
from langchain_core._api import LangChainBetaWarning
from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage, ToolMessage
from langgraph.graph import END
from langgraph.types import Command, Interrupt
@@ -45,12 +43,6 @@ GraphRunInput: TypeAlias = str | Command
LangGraphStreamInput: TypeAlias = dict[str, list[dict[str, object]]] | Command
_ValueMessageKey: TypeAlias = tuple[str, ...]
warnings.filterwarnings(
"ignore",
message=r"The v3 streaming protocol on Pregel is experimental\.",
category=LangChainBetaWarning,
)
@dataclass(frozen=True, slots=True)
class _AssistantValueMessage:
+34 -2
View File
@@ -137,6 +137,34 @@ Moving beyond traditional human-in-the-loop systems, EvoScientist adopts a human
> [!TIP]
> Looking for ready-to-use research skills? Check out [**EvoSkills**](https://github.com/EvoScientist/EvoSkills) — powered by [**EvoScientist**](https://github.com/EvoScientist/EvoScientist)'s engine and installable skills, the entire end-to-end research lifecycle is covered out of the box. [**EvoSkills**](https://github.com/EvoScientist/EvoSkills) are also compatible with other CLI coding agents.
## WebUI Streaming Fix Notes
The WebUI streaming fix is based on one rule: a browser-side SSE disconnect is
only a transport event, not proof that the LangGraph run has finished.
- Keep the composer action button as **Stop** while the backend thread still has
pending work. The UI should not switch back to **Send** merely because the
live SSE stream ended.
- Derive the visible busy state from both the live stream and backend thread
state. In the patched WebUI this is represented as `isRunActive`, backed by
`threads.getState(threadId)`.
- Restore **Send** only after the backend reports a terminal state: idle,
completed final answer, failed, or cancelled.
- Preserve human-in-the-loop controls during tool approvals. Approval buttons
remain actionable, while the bottom composer still shows **Stop** to avoid
duplicate submissions.
- Treat leaked `{"tools":[...]}` payloads as internal tool-selection control
messages, not assistant text, and filter them from the transcript.
- Recover dropped long-response tails through the checkpoint-backed
`/api/threads/{thread_id}/final-answer` endpoint instead of relying only on
browser stream state.
- Use a long enough final-answer polling window for real research runs; short
fallback windows can expire before the backend completes and make the browser
appear truncated until refresh.
- Validate with real browser flows: normal streaming, simulated SSE disconnect
and reload, pending tool approval, rejection/cancellation, and final transition
back to **Send**.
## 🔥 News
- **[03 Jun 2026]** 🥈 Ranked #2 overall — and 🥇 #1 among `GPT-5.4`-based agents — on [ResearchClawBench](https://github.com/InternScience/ResearchClawBench) (Agent Mode)! [**Leaderboard**](https://internscience.github.io/ResearchClawBench-Home/) 👈
- **[18 Apr 2026]** 🥇 Ranked #1 on [DeepResearch Bench](https://deepresearch-bench.github.io/) at submission time! [**Leaderboard**](https://huggingface.co/spaces/muset-ai/DeepResearch-Bench-Leaderboard) 👈
@@ -151,7 +179,7 @@ Moving beyond traditional human-in-the-loop systems, EvoScientist adopts a human
<details>
<summary>📦 Release Highlights — version changelog</summary>
- **[11 Jul 2026]** **[v0.2.2](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.2.2)** — New models selectable in onboarding and `/model`: GPT-5.6 (sol, terra, luna) for OpenAI and OpenRouter, plus Grok 4.5 and Tencent Hunyuan HY3 on OpenRouter; tighter config-file permissions and a reworked onboarding OAuth flow for auxiliary models.
- **[07 Jul 2026]** **[v0.2.2](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.2.2)** — WebUI streaming resilience hotfix: SSE disconnects no longer imply run completion; the composer stays on **Stop** until backend thread state is terminal; final-answer checkpoint recovery fills dropped response tails; tool-selection JSON payloads are filtered from live transcripts; `EVOSCIENTIST_WEBUI_PACKAGE` lets the launcher run a local patched WebUI package for validation before npm publication.
- **[05 Jul 2026]** **[v0.2.1](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.2.1)** — AutoSkills: EvoMemory drafts reusable skills from its own observation clusters for you to review via `/autoskills`; a new `--output-format stream-json` for headless / SDK clients; richer slash-command completions; Windows UTF-8 config reads; a TUI welcome-banner fix; langchain-openrouter 0.2.5.
- **[26 Jun 2026]** **[v0.2.0](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.2.0)** — Scheduled tasks: cron-style recurring runs via `/schedule` or natural language, run unattended with shell-access gating; self-linking memory that connects observations into a knowledge graph (complements / contradicts / supersedes); a read-only `GET /api/models` endpoint for the WebUI model picker.
- **[23 Jun 2026]** **[v0.1.9](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.9)** — Hotfix for fresh installs: the first message crashed with `The subagent `task` tool cannot be exposed via `ptc`` after deepagents 0.6.11 / langchain-quickjs 0.3 reserved `task` as the REPL global. Removed `task` from the code-interpreter PTC allowlist (`task()` stays available as the REPL global; async dispatch stays in PTC) and pinned `deepagents[quickjs]~=0.6.11`.
@@ -181,6 +209,7 @@ Moving beyond traditional human-in-the-loop systems, EvoScientist adopts a human
- [📦 Installation](#-installation)
- [🔑 Configuration](#-configuration)
- [⚡ Quick Start](#-quick-start)
- [WebUI Streaming Fix Notes](#webui-streaming-fix-notes)
- [⏰ Scheduled Tasks](#-scheduled-tasks)
- [🍪 Examples & Recipes](#-examples--recipes)
- [🔌 MCP Integration](#-mcp-integration)
@@ -434,7 +463,10 @@ EvoSci deploy # standalone LangGraph server for external UIs
EvoSci -p "query" --output-format stream-json --auto-mode # JSONL event stream on stdout (for programmatic clients)
```
`--output-format stream-json` makes a single-shot (`-p`) run emit its native events as line-delimited JSON on stdout (one object per line), with all human output on stderr — the integration surface for headless clients (e.g. an agent runtime). See [docs/guides/stream-json.md](docs/guides/stream-json.md) for the event schema.
`--output-format stream-json` makes a single-shot (`-p`) run emit its native
events as line-delimited JSON on stdout (one object per line), with all human
output on stderr — the integration surface for headless clients (e.g. an agent
runtime). See [docs/stream-json.md](docs/stream-json.md) for the event schema.
</details>
-5
View File
@@ -11,11 +11,6 @@
|------------------------------------------------------------|---------------------------------------------------------------------------------|
| [macOS 24/7 Deployment](https://github.com/EvoScientist/EvoScientist/blob/main/docs/recipes/deployment-macos-24h.md#running-evoscientist-247-on-macos-telegram-bot--stt--ccproxy) | Run EvoScientist as an always-on service on macOS with OAuth + Telegram + STT |
| Guide | Description |
|------------------------------------------------------------|---------------------------------------------------------------------------------|
| [`stream-json` output protocol](https://github.com/EvoScientist/EvoScientist/blob/main/docs/guides/stream-json.md#stream-json-output-protocol) | Line-delimited JSON event stream (`--output-format stream-json`) for driving EvoScientist headlessly from SDK / programmatic clients |
## Contributing a Recipe
See the [Contributing Guide](../CONTRIBUTING.md) for general guidelines. When adding a new recipe:
@@ -0,0 +1,303 @@
# TUI as Primary Interface + Run Resilience
**Date:** 2026-07-06
**Status:** Design — pending implementation plan
**Scope:** EvoScientist CLI/TUI launch path, run-interruption recovery, WebUI opt-in hardening
## 1. Background & Root Cause
A user running the WebUI backend (`ui_backend = "webui"`) saw a long research run
(127 s) render truncated output in the browser, ending mid-token at `O-8282d7e`.
Investigation established:
- **The server-side run completed cleanly.** The thread checkpoint in
`~/.evoscientist/sessions.db` holds the full 6818-char final answer
(`status: idle`, `error: None`). The text `O-8282d7e` does not appear at the
end of any stored content — it is a mid-stream artifact.
- **The disconnect is browser-side.** `langgraph dev` sends an SSE heartbeat
every 5 s (`langgraph_api/sse.py:102`), `send_timeout` is `None` (never kills
slow sends), and the server supports resume via the `last-event-id` header
(`langgraph_api/api/runs.py:623`). The `runs/stream` endpoint is **POST**, so
the browser uses `fetch()` to read it; native `EventSource` auto-reconnect does
not apply, and the `@evoscientist/webui` front-end does not re-POST with
`last-event-id` to resume.
- **`stream_resumable=True`** lets the server-side run finish after the browser
drops, which is why "server succeeded" ≠ "browser received".
- A separate symptom from the same session — `Port 6174 cannot be bound` — was a
leftover `langgraph dev` process (PID 15288) whose `atexit` cleanup never ran
(parent killed hard).
**Conclusion:** the SSE disconnect is structural to the WebUI path. The TUI path
does not use SSE at all — `tui_interactive.py:297` calls `create_runtime_gateways()`
with no `backend`, so it defaults to `backend="local"` (`gateway/runtime.py`),
which runs the compiled graph **in-process** and is immune.
## 2. Goals
1. **Make the in-process TUI the primary interface.** Long runs no longer touch
SSE, so the class of failure the user hit cannot occur.
2. **Guarantee the user always sees the complete answer.** Even if a TUI run is
interrupted (Ctrl-C, crash, terminal close), the next invocation can surface
the latest persisted assistant message and optionally resume.
3. **Keep the WebUI available as opt-in** (`--web`), with the launch path
hardened against orphaned processes and port conflicts.
4. **Eliminate the port-6174 / orphaned-`langgraph dev` failure mode** for the
default (TUI) path: it must not start `langgraph dev` at all.
## 3. Non-Goals
- Patching `langgraph_api` upstream.
- Replacing the WebUI front-end's fetch-SSE transport. (`@evoscientist/webui`
lives in a separate npm repo with its own release cycle; a client-side resume
fix is noted as future work, not in scope here.)
- Changing the graph, middleware, or checkpointer semantics.
- Touching the `EvoSci deploy` standalone-server command.
## 4. Architecture
Three launch modes, selected by a single source of truth:
| Mode | `ui_backend` | Runs graph via | Starts `langgraph dev`? |
|---|---|---|---|
| **TUI (default)** | `tui` | `LocalGraphGateway` (in-process) | No |
| CLI | `cli` | `LocalGraphGateway` (in-process) | No |
| WebUI (opt-in) | `webui` | `langgraph dev` over HTTP/SSE | Yes |
A new `--web` flag on the top-level `evoscientist` command forces `webui` for
that invocation regardless of config; `--tui` forces `tui`. This lets users keep
`ui_backend = "webui"` in config but drop into the TUI without editing config,
and vice-versa.
### 4.1 Launch-mode resolution (single source of truth)
Today the default is inconsistent: `config/settings.py:279` defaults `ui_backend`
to `"tui"`, but `cli/tui_runtime.py:12` has `DEFAULT_UI_BACKEND = "cli"`. This
design collapses to one rule, evaluated in order:
1. `--web` flag → `webui`
2. `--tui` flag → `tui`
3. `config.ui_backend` (resolved via existing `resolve_ui_backend`)
4. `"tui"` (hardcoded final fallback)
`resolve_ui_backend` (`cli/tui_runtime.py:41`) stays the single normalizer; the
new flags feed into it rather than bypassing it.
### 4.2 Why TUI is immune (and what can still interrupt it)
In-process execution via `stream_agent_events` has no network hop. The only
"loss" modes are process-level:
- Ctrl-C mid-run
- Terminal close / SIGHUP
- Python crash / OOM
Because the checkpointer is `AsyncSqliteSaver` writing to
`~/.evoscientist/sessions.db` (`sessions.py:108` `get_db_path`), every completed
super-step is on disk before the next begins. An interrupted run therefore
leaves a recoverable checkpoint whose `next` tuple is non-empty (pending nodes)
and whose latest assistant message — if the model node finished — is the same
content the WebUI failed to deliver.
## 5. Components
### C1 — Launch-mode flags + TUI default
**Files:**
- `EvoScientist/cli/__init__.py` (Typer app entry) — add `--web` / `--tui`
options on the root command; thread the resolved `ui_backend` into the existing
dispatch.
- `EvoScientist/cli/tui_runtime.py:12` — remove the divergent
`DEFAULT_UI_BACKEND = "cli"`; route through `resolve_ui_backend` so
`settings.py`'s `"tui"` default wins.
- `EvoScientist/cli/commands.py` — wherever `ui_backend` is currently read, accept
the flag override.
**Behavior:** Running `evoscientist` with no flags and default config launches
the TUI and starts **no** `langgraph dev` subprocess. No port is bound; the
orphan-process class of bug disappears for the default path.
**Acceptance:** `evoscientist` (no args, default config) does not open a
listening socket on 6174 or any other port.
### C2 — TUI run resilience (Approach B)
Two cooperating pieces, both backed by `sessions.db`.
#### C2a — Interrupt-fallback in the streaming loop
Wrap the TUI streaming consumer so that on any interruption
(`KeyboardInterrupt`, `asyncio.CancelledError`, unexpected `Exception`) it reads
the thread's latest checkpoint and renders the latest assistant message before
exiting.
**Files:**
- `EvoScientist/stream/display.py` — `_run_streaming` and
`_astream_to_console` (the two streaming entry points used by
`cli/tui_backends.py`). Add a `finally`/except guard that, after an
interruption, calls a new `render_latest_assistant(thread_id)` helper.
- `EvoScientist/sessions.py` — add `get_latest_assistant_message(thread_id) ->
str | None`: read the most recent checkpoint for the thread, walk `messages`
backwards to the last `AIMessage` with non-empty content, return its text
(joined content blocks). Reuses existing `AsyncSqliteSaver` access already in
this module.
**Behavior on Ctrl-C mid-run:**
1. The guard catches the interrupt.
2. Prints a one-line notice: `Run interrupted — recovering latest output from
checkpoint…`.
3. Renders the latest persisted assistant message via the same Markdown renderer
the normal completion path uses.
4. If no assistant message is persisted yet (model node hadn't completed),
prints `Run interrupted before any output was saved. Resume with: evoscientist
--thread <id>` and exits.
**Non-goal:** this does not *continue* the run; it only guarantees visibility of
whatever completed. Continuation is C2b.
#### C2b — Startup detection of interrupted runs
Runs only when the TUI is launched to **resume a specific thread** (via the
existing thread-resume flag, i.e. the command `resume_hint.py` already prints at
exit). For a brand-new session there is nothing to detect and the prompt is
skipped. When resuming, check the thread's latest checkpoint: `next` is
non-empty **and** no run is currently active for that thread. If so, prompt:
```
You have an unfinished run in thread <id>:
[s] show what completed
[r] resume the run from its last checkpoint
[n] start fresh
```
**Files:**
- `EvoScientist/sessions.py` — add `detect_interrupted_run(thread_id) ->
InterruptedRunInfo | None` returning the checkpoint id, the pending node names
(`next`), and the timestamp. Implemented as a thin read over the existing
checkpointer's `aget_tuple`.
- `EvoScientist/cli/tui_interactive.py` — near the existing thread-load path
(around the `create_runtime_gateways()` call at line 297), invoke the detector
for the active thread and render the prompt above.
**Resume semantics:** selecting `r` re-invokes the graph with the same
`thread_id`. LangGraph resumes from the last checkpoint automatically (this is
the same mechanism `evoscientist --thread <id>` already relies on for session
continuity). No new graph code needed.
**Acceptance:** After killing a TUI mid-run with Ctrl-C, relaunching with
`--thread <id>` offers `[s]`/`[r]`/`[n]`; `[s]` prints the persisted final
answer; `[r]` continues execution from where it stopped.
### C3 — WebUI mode hardening (`--web`)
Applies only when `ui_backend == "webui"`. The goal is to make the existing
`deploy/webui.py` launch path robust and to mitigate (not eliminate) the SSE
limitation.
**Files:**
- `EvoScientist/deploy/webui.py` — three changes:
1. **Process-group + signal cleanup.** Today `atexit.register(stop_langgraph_dev,
...)` only fires on clean exit. Wrap the spawned `langgraph dev` (and the
`npx` child) in a process group (`start_new_session=True` on POSIX; on
Windows use a job object via the existing `_winloop.py` helpers) and
install `SIGINT`/`SIGTERM` handlers that tear the group down. This closes
the orphan-PID-15288 path for normal terminations. `SIGKILL` cannot run
handlers on either platform — document that hard kills may still orphan
the child, and the port-conflict UX in (2) is the user-facing recovery.
2. **Port-conflict UX.** The current "Port X is occupied by another process"
message is already correct; extend it to detect *whether the occupant is a
`langgraph dev`* (via the existing `/ok` health probe) and, if so, offer to
reuse it rather than bail. If the occupant is foreign, print the `lsof`
hint already present.
3. **Resume window.** Set `RESUMABLE_STREAM_TTL_SECONDS=600` in the env passed
to `start_langgraph_dev` so a browser that does reconnect can replay events
for runs up to 10 minutes long (the observed run was 127 s vs. the 120 s
default).
- `EvoScientist/deploy/server.py` — `start_langgraph_dev` already accepts env;
pass `RESUMABLE_STREAM_TTL_SECONDS` through.
**What C3 does NOT do:** it cannot make the browser auto-resume, because that
logic lives in `@evoscientist/webui`. It only widens the server-side window and
stops the orphan process. Documented in the WebUI exit message: *for long runs,
prefer the TUI (`evoscientist --tui`); the browser client does not resume a
dropped stream.*
### C4 — Documentation
- `README` / CLI `--help`: `--web` (browser), `--tui` (terminal, default,
recommended for long runs).
- A short "Why did my WebUI output stop?" note pointing at this design doc, so
future users reading the symptom can find the explanation.
## 6. Data Flow
### Default (TUI) path
```
evoscientist ─▶ resolve_ui_backend ─▶ "tui"
│
(no langgraph dev, no port bound)
▼
create_runtime_gateways(backend="local")
│
LocalGraphGateway.stream_events
│
stream_agent_events (in-process) ── checkpoint per super-step ──▶ sessions.db
│
on interrupt ──▶ get_latest_assistant_message(thread_id) ──▶ render
```
### WebUI (`--web`) path
unchanged from today (langgraph dev + `npx @evoscientist/webui`), plus C3's
process-group cleanup and `RESUMABLE_STREAM_TTL_SECONDS=600`.
## 7. Error Handling
| Failure | TUI default path | WebUI `--web` path |
|---|---|---|
| Ctrl-C mid-run | C2a renders latest persisted assistant message | Browser truncates; user runs `evoscientist --tui --thread <id>` → `[s]` to recover |
| Crash / SIGHUP | Checkpoint on disk; next `--thread <id>` shows `[s]`/`[r]` prompt (C2b) | Same recovery via TUI |
| Port 6174 bound | N/A — no port bound | C3 reuses if `langgraph dev`, else prints `lsof` hint |
| Orphaned `langgraph dev` | Cannot originate from TUI path | C3 process-group + signal handlers tear it down |
## 8. Testing
- **Unit** (`tests/test_sessions_*.py`): `get_latest_assistant_message` returns
the full final answer for a thread whose last run completed; returns `None`
for a thread interrupted before the model node.
- **Unit**: `detect_interrupted_run` returns pending `next` nodes for an
interrupted thread; `None` for an idle one.
- **Integration** (new `tests/test_tui_interrupt_recovery.py`): start a TUI run
against a graph whose model node sleeps, send `SIGINT` mid-run, assert the
process exits 0 after printing the recovered message.
- **Integration**: relaunch with `--thread <id>`, assert the `[s]/[r]/[n]`
prompt appears and `[r]` produces a completed run.
- **Launch** (`tests/test_cli_launch.py`, extend): `evoscientist` with default
config binds no port; `--web` binds 6174 and tears it down on `SIGTERM`.
- **WebUI** (manual / smoke): `--web`, kill parent with `SIGKILL`, assert no
orphan `langgraph dev` remains after the process-group change (best-effort —
`SIGKILL` cannot run handlers, but the process group lets the OS reap
children when the session ends; document this limit).
## 9. Rollout / Migration
- User's config currently has `ui_backend = "webui"`. After this change, that
config still selects WebUI (back-compatible). The user can run `evoscientist
--tui` immediately to get the resilient path, or `EvoSci config set ui_backend
tui` to make it permanent.
- No migration of `sessions.db` — schema is unchanged.
- No breaking change to `EvoSci deploy`.
## 10. Open Questions / Future Work
- **`@evoscientist/webui` client-side resume.** The front-end could re-POST
`/runs/stream` with `last-event-id` (or use the thread's SSE endpoint with
`since`) after a disconnect. This is the only way to fully fix the WebUI
symptom; it lives in the npm repo and is out of scope here. File a
cross-repo issue referencing this design.
- Whether `[r]` resume should replay the *interrupted* model call or only
continue from the *next* node. LangGraph's default (resume from pending
writes) is correct for most cases; revisit if users hit re-execution of
expensive tool calls.
- `cli` backend vs `tui` backend distinction — both are in-process; consider
deprecating the `cli`/`tui` split in a follow-up once the resilience work
lands, to reduce the three-way default confusion that caused the original
`DEFAULT_UI_BACKEND` divergence.
@@ -0,0 +1,196 @@
# WebUI SSE Truncation Recovery via Checkpoint Fallback (α)
**Date:** 2026-07-06
**Status:** Design — pending implementation plan
**Scope:** `@evoscientist/webui` front-end (separate npm repo) + one optional convenience route in this Python repo
## 1. Background & Root Cause
A WebUI user saw a 127 s research run render truncated output, ending mid-token at
`O-8282d7e`. Investigation established:
- The server-side run **completed cleanly**; the thread checkpoint in
`~/.evoscientist/sessions.db` holds the full 6818-char final answer
(`status: idle`, `error: None`).
- The disconnect is **browser-side**: `langgraph dev` sends an SSE heartbeat every
5 s, `send_timeout` is `None`, and the server supports resume via
`last-event-id` (`langgraph_api/api/runs.py:623`). But `/runs/stream` is
**POST**, so the browser reads it via `fetch()`; native `EventSource`
auto-reconnect does not apply, and `@evoscientist/webui` does not re-POST to
resume. When the connection drops mid-run, the front-end is left showing the
last partial token and never fetches the completed answer.
**Core insight:** the server already has the complete answer in the checkpoint.
The fix is to make the front-end fall back to that checkpoint when the stream
ends abnormally. This is small, surgical, and directly addresses the symptom.
## 2. Goal
**The user always sees the complete final answer in the WebUI, even if the SSE
stream drops mid-run.**
## 3. Non-Goals
- Seamless stream resume (tracking `last-event-id` / `seq` and replaying
buffered events). That is option β — better UX, ~3–5× the code, deferred.
- The TUI pivot / launch-mode decoupling (option γ). Separable; only relevant if
the port-conflict / orphan-process issues need addressing.
- Patching `langgraph_api` upstream.
## 4. Detection Logic — What Counts as "Truncated"
The langgraph SSE protocol emits terminal events when a run finishes
(success → `end`, failure → `error`; exact names to be confirmed against the
`@langchain/langgraph-sdk` version during implementation). The front-end's
streaming reader loop currently processes events until the `fetch()` ReadableStream
closes.
**Truncation** = the stream closed (reader returned) **without** a terminal event
having been received. Causes: browser tab throttled, network blip, proxy idle
timeout. On truncation, the in-progress assistant bubble is left showing a prefix
of the real answer.
## 5. Components
### F1 — Truncation detector (front-end)
In the streaming reader loop, track a `terminalSeen` flag. Set it when a
terminal event arrives. When the reader returns, if `!terminalSeen`, mark the
run `truncated` and trigger F2.
**File:** `@evoscientist/webui` — the run-stream consumer (the module that wraps
the langgraph-ts SDK's `runs.stream` and renders into the message list).
### F2 — Checkpoint fallback fetch (front-end)
On `truncated`, fetch the complete final assistant message and **replace** the
in-progress bubble's content with it (not append — the partial tokens are a
prefix of the same message).
Two implementation choices, pick one:
- **F2a (zero server change):** call the standard langgraph endpoint
`GET /threads/{thread_id}/state`, walk `values.messages` backwards to the last
`AIMessage`, join its content blocks. No new route, but the front-end parses
raw state and reimplements "find latest assistant text" logic.
- **F2b (recommended, uses S1 below):** call `GET /api/threads/{id}/final-answer`
→ `{content, completed_at, complete}`. Server owns the parsing; front-end
stays dumb.
Render a subtle affordance (e.g., a dim "⚠ stream dropped — recovered from
checkpoint" line above the message) so the user knows a disconnect happened and
the displayed text is authoritative, not stale streaming.
### F3 — Retry until the run settles (front-end)
The browser may drop while the run is **still executing** server-side. A single
fallback fetch at drop-time could return a not-yet-final message. So F2 retries
with backoff until either:
- `complete == true` (run finished — render and stop), or
- a max wait elapses (default 240 s — comfortably exceeds the observed 127 s
run even if the drop happens at t=0), then render whatever is latest and
stop retrying.
Backoff: poll at 1 s, 2 s, 4 s, then every 5 s up to the max. Each poll replaces
the bubble with the latest content, so if the run finishes mid-retry the user
sees it update to the final form.
### S1 — Convenience route `GET /api/threads/{id}/final-answer` (this repo, optional but recommended)
Enables F2b. Mounted on the existing custom ASGI app already wired via
`langgraph.json` → `EvoScientist.langgraph_dev.http:app` (currently only serves
`/api/models`).
**Contract:**
```
GET /api/threads/{thread_id}/final-answer
200 → { "content": str, # last AIMessage text, blocks joined
"completed_at": iso8601, # run end timestamp, or null
"complete": bool } # true iff run reached terminal state
404 → thread not found
```
**`complete` derivation** (run reached a terminal state):
`thread.status == "idle"` **or** latest checkpoint's `next` tuple is empty.
**File:** `EvoScientist/langgraph_dev/http.py` — add `get_final_answer` route
beside the existing `get_models`. Implementation reads the thread state via the
shared checkpointer (`AsyncSqliteSaver` at `~/.evoscientist/sessions.db`,
already used by `sessions.py`). If direct checkpointer access is awkward from
inside the mounted app, fall back to an internal `langgraph_sdk.get_client()`
call to `GET /threads/{id}/state` on localhost — the contract stays identical.
**Why prefer F2b+S1 over F2a:** the "find latest AIMessage + join content blocks
+ decide complete?" logic is non-trivial (content is a list of typed blocks,
including `reasoning` blocks that should be excluded from the rendered answer —
see the checkpoint: block[0] was `reasoning`, block[1] was `text`). Centralizing
it server-side keeps the front-end thin and makes the same logic reusable by the
TUI's future recovery path if γ is ever pursued.
## 6. Data Flow
```
browser fetch(/runs/stream, POST) ──SSE──▶ langgraph dev
│ │
│ (mid-run, connection drops) │ run continues to completion
▼ │
reader returns, no terminal event ──▶ truncated │
│ │
│ GET /api/threads/{id}/final-answer │
▼ ▼
http.py::get_final_answer ──read checkpoint──▶ sessions.db
│
▼
{content, complete} ──▶ if !complete, retry (F3); else render (F2)
```
## 7. Error Handling
| Case | Behavior |
|---|---|
| Stream ends normally (`end`/`error` received) | F1 sets `terminalSeen`; no fallback; existing render path unchanged |
| Stream drops, run already finished server-side | First fallback fetch returns `complete=true`; render immediately |
| Stream drops, run still executing | F3 retries with backoff; each retry renders latest; stops when `complete` or 120 s max |
| Thread unknown / deleted | 404; front-end shows "stream interrupted and the session could not be recovered" |
| Convenience route unreachable (older server without S1) | Front-end falls back to F2a (raw state endpoint) — degrade gracefully |
## 8. Testing
**Front-end (`@evoscientist/webui`):**
- Unit: feed the reader a synthetic event stream that closes without a terminal
event → assert `truncated == true`; feed one with `end` → `false`.
- Integration: mock `fetch` to drop mid-stream; assert the fallback fetch fires
and the bubble's content is replaced with the checkpoint value.
**Server (this repo, if S1):** new `tests/test_http_final_answer.py`
- Completed thread → 200, `complete=true`, content matches the last AIMessage
text (not the reasoning block).
- Mid-run thread (forced `busy` / non-empty `next`) → 200, `complete=false`.
- Unknown thread → 404.
- Content with mixed `reasoning` + `text` blocks → only `text` in `content`.
## 9. Cross-Repo Coordination & Rollout
- **Order:** land S1 in this Python repo first (route + tests, behind no flag —
it's a pure addition). Then F1/F2/F3 in `@evoscientist/webui`.
- **Versioning:** the WebUI launches via `npx @evoscientist/webui@latest`, so
the front-end fix reaches users on their next launch automatically once
published. No coordinated upgrade required on the Python side.
- **Backward compatibility:** if a user runs a new front-end against an older
Python server without S1, the front-end must detect the 404 and fall back to
F2a (raw state endpoint). If an old front-end runs against a new server, S1
is simply unused — no harm.
## 10. Open Questions / Future Work
- **Exact terminal-event names** for the SDK version in use (`end`/`error` vs.
`done`/`result`). Confirm against `@langchain/langgraph-sdk` during
implementation; the detector is parameterized on this.
- **True resume (β):** track `last-event-id` (or the V2 thread-stream `seq`) and
replay buffered events on reconnect — seamless, no visible "recovered" flash.
Larger front-end effort; defer until α is validated.
- **TUI pivot (γ):** separable work to make the in-process TUI the default and
eliminate the port-conflict / orphan-process issues. Independent spec if
pursued.
-4
View File
@@ -48,7 +48,6 @@ dependencies = [
[dependency-groups]
dev = [
"pytest>=8.0",
"pytest-asyncio>=1.0",
"pytest-cov>=5.0",
"pytest-timeout>=2.4",
"ruff>=0.5",
@@ -59,7 +58,6 @@ dev = [
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=1.0",
"pytest-cov>=5.0",
"pytest-timeout>=2.4",
"ruff>=0.5",
@@ -119,8 +117,6 @@ EvoScientist = [
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = [
"ignore::UserWarning:langchain_nvidia_ai_endpoints",
]
+28
View File
@@ -1,8 +1,36 @@
"""Shared fixtures for EvoScientist tests."""
import asyncio
import pytest
def run_async(coro):
"""Run an async coroutine safely, cancelling pending tasks before closing.
This prevents 'Event loop is closed' errors from asyncio.Queue cleanup
when tasks are still waiting on Queue.get() at teardown time.
"""
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
# Cancel all pending tasks so Queue getters don't raise on close
pending = asyncio.all_tasks(loop)
for task in pending:
task.cancel()
if pending:
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
loop.run_until_complete(loop.shutdown_asyncgens())
loop.close()
@pytest.fixture(name="run_async")
def run_async_fixture():
"""Pytest fixture that exposes run_async as a callable for test functions."""
return run_async
@pytest.fixture(autouse=True)
def _reset_tool_selection_state():
"""Isolate the process-global tool-selection state around every test.
+15 -10
View File
@@ -9,6 +9,7 @@ from typing import Any
from unittest.mock import MagicMock
from EvoScientist.stream.events import stream_agent_events
from tests.conftest import run_async
async def async_iter(items: Iterable[Any]) -> AsyncIterator[Any]:
@@ -16,20 +17,24 @@ async def async_iter(items: Iterable[Any]) -> AsyncIterator[Any]:
yield item
async def collect_events(
def collect_events(
agent,
message: str = "hi",
thread_id: str = "t1",
):
"""Collect stream_agent_events output for tests."""
events = []
async for ev in stream_agent_events(
agent,
message,
thread_id,
):
events.append(ev)
return events
"""Collect stream_agent_events output for synchronous tests."""
async def _run():
events = []
async for ev in stream_agent_events(
agent,
message,
thread_id,
):
events.append(ev)
return events
return run_async(_run())
def protocol_event(
+19 -20
View File
@@ -10,17 +10,18 @@ from EvoScientist.channels.imessage.channel_rpc import (
)
from EvoScientist.channels.qq.channel import QQChannel, QQConfig
from EvoScientist.channels.signal.channel import SignalChannel, SignalConfig
from tests.conftest import run_async as _run
class TestEmailChannelSmoke:
async def test_start_raises_without_required_imap_settings(self):
def test_start_raises_without_required_imap_settings(self):
channel = EmailChannel(EmailConfig())
with pytest.raises(
ChannelError, match="imap_host and imap_username are required"
):
await channel.start()
_run(channel.start())
async def test_send_returns_false_when_smtp_not_ready(self):
def test_send_returns_false_when_smtp_not_ready(self):
channel = EmailChannel(EmailConfig())
msg = OutboundMessage(
channel="email",
@@ -28,16 +29,16 @@ class TestEmailChannelSmoke:
content="hello",
metadata={"chat_id": "user@example.com"},
)
assert await channel.send(msg) is False
assert _run(channel.send(msg)) is False
class TestSignalChannelSmoke:
async def test_start_raises_without_phone_number(self):
def test_start_raises_without_phone_number(self):
channel = SignalChannel(SignalConfig())
with pytest.raises(ChannelError, match="phone_number is required"):
await channel.start()
_run(channel.start())
async def test_send_returns_false_when_not_connected(self):
def test_send_returns_false_when_not_connected(self):
channel = SignalChannel(SignalConfig(phone_number="+123456789"))
msg = OutboundMessage(
channel="signal",
@@ -45,29 +46,27 @@ class TestSignalChannelSmoke:
content="hello",
metadata={"chat_id": "+123456789"},
)
assert await channel.send(msg) is False
assert _run(channel.send(msg)) is False
class TestQQChannelSmoke:
async def test_start_raises_when_sdk_missing(self, monkeypatch):
def test_start_raises_when_sdk_missing(self, monkeypatch):
from EvoScientist.channels.qq import channel as qq_module
monkeypatch.setattr(qq_module, "QQ_AVAILABLE", False)
channel = QQChannel(QQConfig(app_id="id", app_secret="secret"))
with pytest.raises(ChannelError, match="SDK not installed"):
await channel.start()
_run(channel.start())
async def test_start_raises_without_credentials_when_sdk_available(
self, monkeypatch
):
def test_start_raises_without_credentials_when_sdk_available(self, monkeypatch):
from EvoScientist.channels.qq import channel as qq_module
monkeypatch.setattr(qq_module, "QQ_AVAILABLE", True)
channel = QQChannel(QQConfig(app_id="", app_secret=""))
with pytest.raises(ChannelError, match="app_id and app_secret are required"):
await channel.start()
_run(channel.start())
async def test_send_returns_false_without_client(self):
def test_send_returns_false_without_client(self):
channel = QQChannel(QQConfig(app_id="id", app_secret="secret"))
msg = OutboundMessage(
channel="qq",
@@ -75,11 +74,11 @@ class TestQQChannelSmoke:
content="hello",
metadata={"chat_id": "openid"},
)
assert await channel.send(msg) is False
assert _run(channel.send(msg)) is False
class TestIMessageChannelSmoke:
async def test_start_wraps_rpc_bootstrap_error(self, monkeypatch):
def test_start_wraps_rpc_bootstrap_error(self, monkeypatch):
async def _broken_start(self):
raise RuntimeError("imsg not found")
@@ -88,9 +87,9 @@ class TestIMessageChannelSmoke:
monkeypatch.setattr(imessage_module.ImsgRpcClient, "start", _broken_start)
channel = IMessageChannelRpc(IMessageConfig())
with pytest.raises(ChannelError, match="Failed to start imsg"):
await channel.start()
_run(channel.start())
async def test_send_returns_false_without_rpc_client(self):
def test_send_returns_false_without_rpc_client(self):
channel = IMessageChannelRpc(IMessageConfig())
msg = OutboundMessage(
channel="imessage",
@@ -98,4 +97,4 @@ class TestIMessageChannelSmoke:
content="hello",
metadata={"chat_id": "+123456789"},
)
assert await channel.send(msg) is False
assert _run(channel.send(msg)) is False
-71
View File
@@ -1,71 +0,0 @@
def test_create_cli_agent_accepts_host_backend_and_memory_options(
monkeypatch, tmp_path
):
import EvoScientist.EvoScientist as agent_module
from EvoScientist.config.settings import EvoScientistConfig
calls = {}
workspace_backend = object()
chat_model = object()
class _CompositeBackend:
def __init__(self, *, default, routes):
calls["default_backend"] = default
calls["routes"] = routes
class _MemoryBackend:
def __init__(self, **kwargs):
calls["memory_backend_kwargs"] = kwargs
class _SkillsBackend:
def __init__(self, **kwargs):
calls["skills_backend_kwargs"] = kwargs
class _Agent:
def with_config(self, config):
calls["agent_config"] = config
return self
cfg = EvoScientistConfig(auto_approve=True, recursion_limit=321)
monkeypatch.setattr("deepagents.backends.CompositeBackend", _CompositeBackend)
monkeypatch.setattr("deepagents.create_deep_agent", lambda **kwargs: _Agent())
monkeypatch.setattr("EvoScientist.backends.MemoryFilesystemBackend", _MemoryBackend)
monkeypatch.setattr("EvoScientist.backends.MergedSkillsBackend", _SkillsBackend)
monkeypatch.setattr(agent_module, "set_active_workspace", lambda path: None)
monkeypatch.setattr(
agent_module,
"_get_default_middleware",
lambda **kwargs: calls.setdefault("middleware_kwargs", kwargs) or [],
)
monkeypatch.setattr(
agent_module,
"load_mcp_and_build_kwargs",
lambda *args, **kwargs: {"subagents": [{"name": "research"}]},
)
memory_dir = tmp_path / "memory"
result = agent_module.create_cli_agent(
workspace_dir=str(tmp_path / "workspace"),
checkpointer=object(),
config=cfg,
chat_model=chat_model,
workspace_backend=workspace_backend,
memory_dir=memory_dir,
tool_selector_threshold=8,
memory_max_inline_profile_chars=1000,
enable_subagents=False,
enable_background_execution=False,
)
assert isinstance(result, _Agent)
assert calls["default_backend"] is workspace_backend
assert calls["memory_backend_kwargs"] == {
"root_dir": str(memory_dir),
"virtual_mode": True,
}
assert calls["middleware_kwargs"]["memory_dir"] == str(memory_dir)
assert calls["middleware_kwargs"]["tool_selector_threshold"] == 8
assert calls["middleware_kwargs"]["memory_max_inline_profile_chars"] == 1000
assert calls["middleware_kwargs"]["enable_background_execution"] is False
assert calls["agent_config"] == {"recursion_limit": 321}
+146 -123
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import asyncio
import threading
import pytest
@@ -94,95 +93,88 @@ def _make_loader_fn(agent_value="AGENT", fail_with=None, capture=None):
return _loader
class _GatedThreadLoader:
"""Callable loader that blocks until tests explicitly release it."""
def __init__(self, agent_value="AGENT", progress_events=()):
self.agent_value = agent_value
self.progress_events = tuple(progress_events)
self.started = threading.Event()
self.release = threading.Event()
self.finished = threading.Event()
def __call__(self, *, on_mcp_progress=None):
self.started.set()
self.release.wait(timeout=1)
try:
if on_mcp_progress is not None:
for event in self.progress_events:
on_mcp_progress(*event)
return self.agent_value
finally:
self.finished.set()
async def _wait_for_event(event, timeout=1):
return await asyncio.to_thread(event.wait, timeout)
def _run(coro):
return asyncio.run(coro)
class TestBackgroundAgentLoaderStart:
async def test_start_creates_task_and_forwards_kwargs(self):
def test_start_creates_task_and_forwards_kwargs(self):
captured: dict = {}
loader = BackgroundAgentLoader(_make_loader_fn(capture=captured))
loader.start(workspace_dir="/ws", checkpointer="CK")
assert loader.task is not None
assert loader.is_pending
await loader.await_ready()
async def _go():
loader.start(workspace_dir="/ws", checkpointer="CK")
assert loader.task is not None
assert loader.is_pending
await loader.await_ready()
_run(_go())
assert captured["kwargs"][0] == {"workspace_dir": "/ws", "checkpointer": "CK"}
async def test_start_bumps_load_id(self):
def test_start_bumps_load_id(self):
loader = BackgroundAgentLoader(_make_loader_fn())
assert loader._load_id == 0
loader.start()
assert loader._load_id == 1
loader.start()
assert loader._load_id == 2
await loader.await_ready()
async def _go():
assert loader._load_id == 0
loader.start()
assert loader._load_id == 1
loader.start()
assert loader._load_id == 2
await loader.await_ready()
async def test_start_cancels_in_flight_prior_task(self):
blocking = _GatedThreadLoader("LATE")
_run(_go())
loader = BackgroundAgentLoader(blocking)
loader.start()
first_task = loader.task
assert first_task is not None
assert await _wait_for_event(blocking.started)
# Supersede immediately; asyncio.to_thread wrapper gets cancelled.
loader._loader_fn = _make_loader_fn("FRESH")
loader.start()
agent = await loader.await_ready()
assert agent == "FRESH"
blocking.release.set()
try:
await first_task
except asyncio.CancelledError:
pass
assert first_task.cancelled() or first_task.done()
def test_start_cancels_in_flight_prior_task(self):
import time
def _blocking(*, on_mcp_progress=None):
time.sleep(0.05)
return "LATE"
async def _go():
loader = BackgroundAgentLoader(_blocking)
loader.start()
first_task = loader.task
# Supersede immediately; asyncio.to_thread wrapper gets cancelled.
loader._loader_fn = _make_loader_fn("FRESH")
loader.start()
agent = await loader.await_ready()
assert agent == "FRESH"
# Let the first thread drain so its done callback (gated) fires.
await asyncio.sleep(0.1)
assert first_task.cancelled() or first_task.done()
_run(_go())
class TestBackgroundAgentLoaderCallbacks:
async def test_progress_hook_sees_events_in_order(self):
def test_progress_hook_sees_events_in_order(self):
events: list[tuple[str, str, str]] = []
loader = BackgroundAgentLoader(
_make_loader_fn(capture={}),
on_progress=lambda e, s, d: events.append((e, s, d)),
)
loader.start()
await loader.await_ready()
async def _go():
loader.start()
await loader.await_ready()
_run(_go())
assert events == [("start", "srv", ""), ("success", "srv", "1")]
async def test_stale_progress_events_are_dropped(self):
def test_stale_progress_events_are_dropped(self):
"""A progress event fired after a newer `start` must not reach the hook."""
slow_loader = _GatedThreadLoader(
"slow-agent", progress_events=[("success", "from-slow", "1")]
)
import time
seen: list[str] = []
# Loader 1 sleeps so its progress event fires AFTER load 2 starts.
def slow_loader(*, on_mcp_progress=None):
time.sleep(0.08)
if on_mcp_progress is not None:
on_mcp_progress("success", "from-slow", "1")
return "slow-agent"
def fast_loader(*, on_mcp_progress=None):
if on_mcp_progress is not None:
on_mcp_progress("success", "from-fast", "1")
@@ -192,32 +184,36 @@ class TestBackgroundAgentLoaderCallbacks:
slow_loader, on_progress=lambda e, s, d: seen.append(s)
)
loader.start()
assert await _wait_for_event(slow_loader.started)
# Loader 1 waits so its progress event fires AFTER load 2 starts.
loader._loader_fn = fast_loader
loader.start()
await loader.await_ready()
slow_loader.release.set()
assert await _wait_for_event(slow_loader.finished)
async def _go():
loader.start()
# Supersede before the slow thread's event fires.
await asyncio.sleep(0.01)
loader._loader_fn = fast_loader
loader.start()
await loader.await_ready()
# Let the superseded thread finish (its event is gated out).
await asyncio.sleep(0.1)
_run(_go())
assert "from-fast" in seen
assert "from-slow" not in seen
async def test_success_callback_fires_on_completion(self):
def test_success_callback_fires_on_completion(self):
got = []
loader = BackgroundAgentLoader(
_make_loader_fn("MY_AGENT"),
on_success=lambda a: got.append(a),
)
loader.start()
await loader.await_ready()
await asyncio.sleep(0) # let done-callback run
async def _go():
loader.start()
await loader.await_ready()
await asyncio.sleep(0) # let done-callback run
_run(_go())
assert got == ["MY_AGENT"]
async def test_failure_callback_fires_on_error(self):
def test_failure_callback_fires_on_error(self):
err = RuntimeError("load failed")
got_failures = []
got_successes = []
@@ -227,33 +223,40 @@ class TestBackgroundAgentLoaderCallbacks:
on_failure=lambda e: got_failures.append(e),
)
loader.start()
with pytest.raises(RuntimeError, match="load failed"):
await loader.await_ready()
await asyncio.sleep(0)
async def _go():
loader.start()
with pytest.raises(RuntimeError, match="load failed"):
await loader.await_ready()
await asyncio.sleep(0)
_run(_go())
assert got_failures == [err]
assert got_successes == []
class TestBackgroundAgentLoaderAwaitReady:
async def test_returns_cached_agent_without_reawaiting(self):
def test_returns_cached_agent_without_reawaiting(self):
captured: dict = {}
loader = BackgroundAgentLoader(_make_loader_fn("A", capture=captured))
loader.start()
assert await loader.await_ready() == "A"
assert await loader.await_ready() == "A"
async def _go():
loader.start()
assert await loader.await_ready() == "A"
assert await loader.await_ready() == "A"
_run(_go())
assert len(captured["kwargs"]) == 1
async def test_raises_if_started_not_called(self):
def test_raises_if_started_not_called(self):
loader = BackgroundAgentLoader(_make_loader_fn())
with pytest.raises(RuntimeError, match="before start"):
await loader.await_ready()
async def _go():
with pytest.raises(RuntimeError, match="before start"):
await loader.await_ready()
async def test_reraises_real_error_on_subsequent_awaits(self):
_run(_go())
def test_reraises_real_error_on_subsequent_awaits(self):
"""After a failure, ``await_ready`` must keep raising the real exception —
not the "before start()" sentinel — until ``start`` is called again."""
@@ -262,13 +265,16 @@ class TestBackgroundAgentLoaderAwaitReady:
loader = BackgroundAgentLoader(_fail)
loader.start()
with pytest.raises(RuntimeError, match="bad MCP config"):
await loader.await_ready()
with pytest.raises(RuntimeError, match="bad MCP config"):
await loader.await_ready()
async def _go():
loader.start()
with pytest.raises(RuntimeError, match="bad MCP config"):
await loader.await_ready()
with pytest.raises(RuntimeError, match="bad MCP config"):
await loader.await_ready()
async def test_needs_restart_flags_failed_load_for_retry(self):
_run(_go())
def test_needs_restart_flags_failed_load_for_retry(self):
calls = {"n": 0}
def flaky(*, on_mcp_progress=None):
@@ -279,14 +285,17 @@ class TestBackgroundAgentLoaderAwaitReady:
loader = BackgroundAgentLoader(flaky)
assert loader.needs_restart # never started
loader.start()
with pytest.raises(RuntimeError):
await loader.await_ready()
assert loader.needs_restart # failed, caller may retry
loader.start()
assert await loader.await_ready() == "SECOND"
assert not loader.needs_restart # success → no retry
async def _go():
assert loader.needs_restart # never started
loader.start()
with pytest.raises(RuntimeError):
await loader.await_ready()
assert loader.needs_restart # failed, caller may retry
loader.start()
assert await loader.await_ready() == "SECOND"
assert not loader.needs_restart # success → no retry
_run(_go())
class TestBackgroundAgentLoaderAdopt:
@@ -296,19 +305,26 @@ class TestBackgroundAgentLoaderAdopt:
assert loader.agent == "EXTERNAL"
assert not loader.is_pending
async def test_adopt_supersedes_in_flight_load(self):
def test_adopt_supersedes_in_flight_load(self):
"""A late background completion must not overwrite an adopted agent."""
slow_loader = _GatedThreadLoader("FROM_BACKGROUND")
import time
loader = BackgroundAgentLoader(slow_loader)
def _slow(*, on_mcp_progress=None):
time.sleep(0.08)
return "FROM_BACKGROUND"
loader.start()
assert await _wait_for_event(slow_loader.started)
loader.adopt("FROM_MODEL")
slow_loader.release.set()
assert await _wait_for_event(slow_loader.finished)
await asyncio.sleep(0)
assert loader.agent == "FROM_MODEL"
loader = BackgroundAgentLoader(_slow)
async def _go():
loader.start()
await asyncio.sleep(0.01)
loader.adopt("FROM_MODEL")
# Give the background thread time to finish and fire its
# done-callback; the generation token should make it a no-op.
await asyncio.sleep(0.1)
assert loader.agent == "FROM_MODEL"
_run(_go())
class TestBackgroundAgentLoaderIsPending:
@@ -316,22 +332,29 @@ class TestBackgroundAgentLoaderIsPending:
loader = BackgroundAgentLoader(_make_loader_fn())
assert not loader.is_pending
async def test_false_after_completion(self):
def test_false_after_completion(self):
loader = BackgroundAgentLoader(_make_loader_fn())
loader.start()
await loader.await_ready()
async def _go():
loader.start()
await loader.await_ready()
_run(_go())
assert not loader.is_pending
async def test_true_between_start_and_completion(self):
wait_loader = _GatedThreadLoader("ok")
def test_true_between_start_and_completion(self):
import time
loader = BackgroundAgentLoader(wait_loader)
def _wait_loader(*, on_mcp_progress=None):
time.sleep(0.05)
return "ok"
loader.start()
assert await _wait_for_event(wait_loader.started)
assert loader.is_pending
wait_loader.release.set()
await loader.await_ready()
assert not loader.is_pending
loader = BackgroundAgentLoader(_wait_loader)
async def _go():
loader.start()
assert loader.is_pending
await loader.await_ready()
assert not loader.is_pending
_run(_go())
+201 -129
View File
@@ -5,8 +5,6 @@ import queue
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from EvoScientist.cli import async_notifier
from EvoScientist.cli.async_notifier import (
dedup_notifications,
@@ -30,6 +28,12 @@ def test_notification_dataclass_fields():
def test_notification_queue_is_module_level_fifo():
# Drain anything left over from other tests
while True:
try:
async_notifier._notification_queue.get_nowait()
except queue.Empty:
break
n1 = async_notifier.AsyncTaskNotification("a", "x", "success", "")
n2 = async_notifier.AsyncTaskNotification("b", "x", "success", "")
async_notifier._notification_queue.put(n1)
@@ -47,7 +51,7 @@ def _drain_queue(q):
return items
async def test_read_async_tasks_from_gateway_reads_state_values():
def test_read_async_tasks_from_gateway_reads_state_values(run_async):
gateway = FakeGraphGateway(
state_values={
"async_tasks": {
@@ -56,16 +60,18 @@ async def test_read_async_tasks_from_gateway_reads_state_values():
}
)
tasks = await async_notifier.read_async_tasks_from_gateway(
gateway,
GraphTarget(local_graph=MagicMock()),
"tid",
tasks = run_async(
async_notifier.read_async_tasks_from_gateway(
gateway,
GraphTarget(local_graph=MagicMock()),
"tid",
)
)
assert tasks == {"task-1": {"status": "success"}}
async def test_watcher_pushes_notification_on_stream_end():
def test_watcher_pushes_notification_on_stream_end(run_async):
# Stream yields one "values" chunk with the final state, then closes
final_state = {
"messages": [{"type": "ai", "content": "Quantum superposition is..."}]
@@ -81,7 +87,10 @@ async def test_watcher_pushes_notification_on_stream_end():
# runs.get is used to fetch terminal status when stream ends
client.runs.get = AsyncMock(return_value={"status": "success"})
await async_notifier.watch_run_and_notify(client, "thr-1", "run-1", "writing-agent")
_drain_all(async_notifier)
run_async(
async_notifier.watch_run_and_notify(client, "thr-1", "run-1", "writing-agent")
)
notifs = _drain_queue(async_notifier._notification_queue)
assert len(notifs) == 1
@@ -90,7 +99,7 @@ async def test_watcher_pushes_notification_on_stream_end():
assert notifs[0].status == "success"
async def test_watcher_pushes_error_status_on_stream_exception():
def test_watcher_pushes_error_status_on_stream_exception(run_async):
async def fake_stream(*a, **kw):
raise RuntimeError("network broken")
yield # unreachable; makes this an async generator
@@ -102,13 +111,14 @@ async def test_watcher_pushes_error_status_on_stream_exception():
return_value={"status": "error", "error": "network broken"}
)
await async_notifier.watch_run_and_notify(client, "thr-4", "run-4", "agentZ")
_drain_all(async_notifier)
run_async(async_notifier.watch_run_and_notify(client, "thr-4", "run-4", "agentZ"))
notif = async_notifier._notification_queue.get_nowait()
assert notif.status == "error"
async def test_spawn_watcher_replaces_existing_for_same_thread():
def test_spawn_watcher_replaces_existing_for_same_thread(run_async):
"""A second spawn_watcher with the same thread_id cancels the old watcher
and registers the new one — supports update_async_task creating a new
run_id on the same thread_id."""
@@ -128,34 +138,43 @@ async def test_spawn_watcher_replaces_existing_for_same_thread():
client.runs.join_stream = fake_stream_long
client.runs.get = AsyncMock(return_value={"status": "success"})
# First spawn for thread X, run R1
t1 = async_notifier.spawn_watcher(client, "thr-X", "R1", "agent")
assert t1 is not None
assert async_notifier._watcher_by_thread["thr-X"] is t1
await asyncio.sleep(0.02) # let it start streaming
async def scenario():
# Clear all queues and the watcher registries
async_notifier._active_watchers.clear()
async_notifier._watcher_by_thread.clear()
_drain_all(async_notifier)
# Second spawn for SAME thread X, NEW run R2
t2 = async_notifier.spawn_watcher(client, "thr-X", "R2", "agent")
assert t2 is not None
assert t2 is not t1
assert async_notifier._watcher_by_thread["thr-X"] is t2
# First spawn for thread X, run R1
t1 = async_notifier.spawn_watcher(client, "thr-X", "R1", "agent")
assert t1 is not None
assert async_notifier._watcher_by_thread["thr-X"] is t1
await asyncio.sleep(0.02) # let it start streaming
# Old watcher should be cancelled
await asyncio.sleep(0.02)
assert t1.cancelled() or t1.done()
# Second spawn for SAME thread X, NEW run R2
t2 = async_notifier.spawn_watcher(client, "thr-X", "R2", "agent")
assert t2 is not None
assert t2 is not t1
assert async_notifier._watcher_by_thread["thr-X"] is t2
# Cleanup the new task too
t2.cancel()
try:
await t2
except asyncio.CancelledError:
pass
# Old watcher should be cancelled
await asyncio.sleep(0.02)
assert t1.cancelled() or t1.done()
# Cancelled watchers don't push notifications
assert _drain_one_queue_helper(async_notifier._notification_queue) == []
assert _drain_one_queue_helper(async_notifier._unrouted_queue) == []
for q in async_notifier._notifications_by_thread.values():
assert _drain_one_queue_helper(q) == []
# Cleanup the new task too
t2.cancel()
try:
await t2
except asyncio.CancelledError:
pass
# Cancelled watchers don't push notifications
assert _drain_one_queue_helper(async_notifier._notification_queue) == []
assert _drain_one_queue_helper(async_notifier._unrouted_queue) == []
if hasattr(async_notifier, "_notifications_by_thread"):
for q in async_notifier._notifications_by_thread.values():
assert _drain_one_queue_helper(q) == []
run_async(scenario())
# ============================================================================
@@ -305,6 +324,13 @@ def test_format_notification_lines_timeout_uses_warning_icon():
def test_drain_returns_all_pending_and_empties_queue():
"""drain_notifications pulls every pending notification and empties queue."""
# Clear the queue first
while True:
try:
async_notifier._notification_queue.get_nowait()
except queue.Empty:
break
# Add three notifications
for tid in ("a", "b", "c"):
async_notifier._notification_queue.put(
@@ -437,12 +463,17 @@ def test_dedup_preserves_order():
# ============================================================================
async def test_consume_notifications_calls_runner_with_batched_message():
def test_consume_notifications_calls_runner_with_batched_message(run_async):
"""When notifications arrive and agent is idle, consume_notifications fires
the supplied async runner once with the formatted batch message and notifs list."""
from EvoScientist.cli import async_notifier as an
# Set up two pending notifications, no dedup match
while True:
try:
an._notification_queue.get_nowait()
except queue.Empty:
break
an._notification_queue.put(an.AsyncTaskNotification("t1", "wA", "success", "", ""))
an._notification_queue.put(an.AsyncTaskNotification("t2", "wB", "success", "", ""))
@@ -455,15 +486,21 @@ async def test_consume_notifications_calls_runner_with_batched_message():
async def fake_state_reader() -> dict:
return {} # no dedup info
await an.consume_notifications(fake_runner, fake_state_reader)
run_async(an.consume_notifications(fake_runner, fake_state_reader))
assert "wA" in captured["text"]
assert "wB" in captured["text"]
assert len(captured["notifs"]) == 2
async def test_consume_notifications_no_op_when_queue_empty():
def test_consume_notifications_no_op_when_queue_empty(run_async):
from EvoScientist.cli import async_notifier as an
while True:
try:
an._notification_queue.get_nowait()
except queue.Empty:
break
called = False
async def fake_runner(text: str, notifs: list):
@@ -473,7 +510,7 @@ async def test_consume_notifications_no_op_when_queue_empty():
async def fake_state_reader():
return {}
await an.consume_notifications(fake_runner, fake_state_reader)
run_async(an.consume_notifications(fake_runner, fake_state_reader))
assert called is False
@@ -484,7 +521,7 @@ async def test_consume_notifications_no_op_when_queue_empty():
# ============================================================================
async def test_notification_consuming_flag_prevents_reentry():
def test_notification_consuming_flag_prevents_reentry(run_async):
"""The _notification_consuming guard prevents two overlapping consumers.
Verifies the flag contract used by _consume_notifications_tui:
@@ -499,6 +536,13 @@ async def test_notification_consuming_flag_prevents_reentry():
"""
from EvoScientist.cli import async_notifier as an
# Clear the queue
while True:
try:
an._notification_queue.get_nowait()
except queue.Empty:
break
state = {"inject_count": 0, "consuming": False}
async def counting_runner(text: str, notifs: list) -> None:
@@ -521,42 +565,45 @@ async def test_notification_consuming_flag_prevents_reentry():
n1 = an.AsyncTaskNotification("g1", "writing-agent", "success", "", "")
n2 = an.AsyncTaskNotification("g2", "data-agent", "success", "", "")
# Scenario 1: normal flow — flag cleared, second consumer runs fine.
await guarded_consume(n1)
assert state["inject_count"] == 1
assert state["consuming"] is False # finally ran
async def scenario():
# Scenario 1: normal flow — flag cleared, second consumer runs fine.
await guarded_consume(n1)
assert state["inject_count"] == 1
assert state["consuming"] is False # finally ran
state["inject_count"] = 0
await guarded_consume(n2)
assert state["inject_count"] == 1
assert state["consuming"] is False
state["inject_count"] = 0
await guarded_consume(n2)
assert state["inject_count"] == 1
assert state["consuming"] is False
# Scenario 2: flag pre-set (first consumer in-flight) → second bails.
state["inject_count"] = 0
state["consuming"] = True # simulate first consumer running
an._notification_queue.put(n1)
await guarded_consume(n1) # should be blocked immediately
assert state["inject_count"] == 0 # runner never called
state["consuming"] = False # cleanup
# Scenario 2: flag pre-set (first consumer in-flight) → second bails.
state["inject_count"] = 0
state["consuming"] = True # simulate first consumer running
an._notification_queue.put(n1)
await guarded_consume(n1) # should be blocked immediately
assert state["inject_count"] == 0 # runner never called
state["consuming"] = False # cleanup
# Scenario 3: exception in runner → flag still cleared by finally.
async def raising_runner(text: str, notifs: list) -> None:
raise RuntimeError("boom")
# Scenario 3: exception in runner → flag still cleared by finally.
async def raising_runner(text: str, notifs: list) -> None:
raise RuntimeError("boom")
async def guarded_consume_raising(notif):
if state["consuming"]:
return
state["consuming"] = True
try:
an._notification_queue.put(notif)
await an.consume_notifications(raising_runner, fake_state_reader)
except RuntimeError:
pass
finally:
state["consuming"] = False
async def guarded_consume_raising(notif):
if state["consuming"]:
return
state["consuming"] = True
try:
an._notification_queue.put(notif)
await an.consume_notifications(raising_runner, fake_state_reader)
except RuntimeError:
pass
finally:
state["consuming"] = False
await guarded_consume_raising(n2)
assert state["consuming"] is False # cleared despite exception
await guarded_consume_raising(n2)
assert state["consuming"] is False # cleared despite exception
run_async(scenario())
# ============================================================================
@@ -566,42 +613,33 @@ async def test_notification_consuming_flag_prevents_reentry():
def _drain_all(an_mod):
"""Drain every queue (per-thread + unrouted) so tests start clean."""
while True:
try:
an_mod._notification_queue.get_nowait()
except queue.Empty:
break
for q in list(an_mod._notifications_by_thread.values()):
if hasattr(an_mod, "_notification_queue"):
while True:
try:
q.get_nowait()
an_mod._notification_queue.get_nowait()
except queue.Empty:
break
if hasattr(an_mod, "_notifications_by_thread"):
for q in list(an_mod._notifications_by_thread.values()):
while True:
try:
q.get_nowait()
except queue.Empty:
break
if hasattr(an_mod, "_unrouted_queue"):
while True:
try:
an_mod._unrouted_queue.get_nowait()
except queue.Empty:
break
while True:
try:
an_mod._unrouted_queue.get_nowait()
except queue.Empty:
break
def _reset_notifier_state(an_mod):
_drain_all(an_mod)
an_mod._active_watchers.clear()
an_mod._watcher_by_thread.clear()
@pytest.fixture(autouse=True)
def _clean_async_notifier_state():
_reset_notifier_state(async_notifier)
yield
_reset_notifier_state(async_notifier)
async def test_consume_only_drains_matching_thread():
def test_consume_only_drains_matching_thread(run_async):
"""Notifications tagged with origin_cli_thread_id only drain when the
consumer is invoked with the matching current_thread_id."""
from EvoScientist.cli import async_notifier as an
_drain_all(an)
n_a = an.AsyncTaskNotification(
"tA", "writing-agent", "success", "", "", origin_cli_thread_id="threadA"
)
@@ -619,17 +657,21 @@ async def test_consume_only_drains_matching_thread():
async def state_reader() -> dict:
return {}
await an.consume_notifications(runner, state_reader, current_thread_id="threadA")
run_async(
an.consume_notifications(runner, state_reader, current_thread_id="threadA")
)
assert captured["runs"] == [["tA"]]
# B's notification should still be queued
assert an.has_pending_notifications("threadB")
_drain_all(an)
async def test_unrouted_notifications_drain_on_any_thread():
def test_unrouted_notifications_drain_on_any_thread(run_async):
"""Notifications without origin_cli_thread_id (legacy / direct put) drain
regardless of the current_thread_id arg."""
from EvoScientist.cli import async_notifier as an
_drain_all(an)
an._notification_queue.put(
an.AsyncTaskNotification("tU", "writing-agent", "success", "", "")
)
@@ -642,15 +684,19 @@ async def test_unrouted_notifications_drain_on_any_thread():
async def state_reader() -> dict:
return {}
await an.consume_notifications(runner, state_reader, current_thread_id="anything")
run_async(
an.consume_notifications(runner, state_reader, current_thread_id="anything")
)
assert [n.task_id for n in captured["notifs"]] == ["tU"]
_drain_all(an)
async def test_thread_switch_drains_pending():
def test_thread_switch_drains_pending(run_async):
"""Pending notifications for thread B are not delivered while consumer
asks for thread A; once consumer runs with thread B they drain."""
from EvoScientist.cli import async_notifier as an
_drain_all(an)
an._enqueue(
an.AsyncTaskNotification(
"tB", "writing-agent", "success", "", "", origin_cli_thread_id="threadB"
@@ -666,19 +712,25 @@ async def test_thread_switch_drains_pending():
return {}
# First consume in thread A → no drain, B's notif still queued
await an.consume_notifications(runner, state_reader, current_thread_id="threadA")
run_async(
an.consume_notifications(runner, state_reader, current_thread_id="threadA")
)
assert captured["runs"] == []
assert an.has_pending_notifications("threadB")
# Now switch to thread B → drains
await an.consume_notifications(runner, state_reader, current_thread_id="threadB")
run_async(
an.consume_notifications(runner, state_reader, current_thread_id="threadB")
)
assert captured["runs"] == [["tB"]]
_drain_all(an)
def test_has_pending_notifications_respects_routing():
"""has_pending_notifications returns true only for matching or unrouted."""
from EvoScientist.cli import async_notifier as an
_drain_all(an)
# Unrouted always counts
an._notification_queue.put(
an.AsyncTaskNotification("tU", "writing-agent", "success", "", "")
@@ -696,6 +748,7 @@ def test_has_pending_notifications_respects_routing():
assert an.has_pending_notifications("threadA") is True
assert an.has_pending_notifications("threadB") is False
assert an.has_pending_notifications() is False # no unrouted, no current_thread
_drain_all(an)
# ============================================================================
@@ -709,7 +762,7 @@ def test_has_pending_notifications_respects_routing():
# ============================================================================
async def test_watcher_reports_error_on_in_band_error_event():
def test_watcher_reports_error_on_in_band_error_event(run_async):
"""SSE error event in the stream → notification.status == 'error'."""
async def fake_stream(*a, **kw):
@@ -724,7 +777,8 @@ async def test_watcher_reports_error_on_in_band_error_event():
return_value={"status": "success"}
) # would mislead — should NOT be consulted
await async_notifier.watch_run_and_notify(client, "thrE", "rE", "agentE")
_drain_all(async_notifier)
run_async(async_notifier.watch_run_and_notify(client, "thrE", "rE", "agentE"))
notif = async_notifier._notification_queue.get_nowait()
assert notif.status == "error"
@@ -732,7 +786,7 @@ async def test_watcher_reports_error_on_in_band_error_event():
client.runs.get.assert_not_awaited()
async def test_watcher_clean_exit_with_runs_get_success_is_success():
def test_watcher_clean_exit_with_runs_get_success_is_success(run_async):
"""Clean stream exit + runs.get reports success → status=success."""
async def fake_stream(*a, **kw):
@@ -744,14 +798,15 @@ async def test_watcher_clean_exit_with_runs_get_success_is_success():
client.runs.join_stream = fake_stream
client.runs.get = AsyncMock(return_value={"status": "success"})
await async_notifier.watch_run_and_notify(client, "thrS", "rS", "agentS")
_drain_all(async_notifier)
run_async(async_notifier.watch_run_and_notify(client, "thrS", "rS", "agentS"))
notif = async_notifier._notification_queue.get_nowait()
assert notif.status == "success"
client.runs.get.assert_awaited_once()
async def test_watcher_clean_exit_with_runs_get_error_is_race_safe():
def test_watcher_clean_exit_with_runs_get_error_is_race_safe(run_async):
"""Clean stream exit + no in-band error event + runs.get returns 'error'
→ status=success (race-safe).
@@ -772,13 +827,14 @@ async def test_watcher_clean_exit_with_runs_get_error_is_race_safe():
client.runs.join_stream = fake_stream
client.runs.get = AsyncMock(return_value={"status": "error"})
await async_notifier.watch_run_and_notify(client, "thrS", "rS", "agentS")
_drain_all(async_notifier)
run_async(async_notifier.watch_run_and_notify(client, "thrS", "rS", "agentS"))
notif = async_notifier._notification_queue.get_nowait()
assert notif.status == "success"
async def test_watcher_clean_exit_with_runs_get_running_drops_notification():
def test_watcher_clean_exit_with_runs_get_running_drops_notification(run_async):
"""Reproduces the production bug: clean SSE close while run is still
actually running (HTTP keep-alive timeout under concurrency).
@@ -800,8 +856,11 @@ async def test_watcher_clean_exit_with_runs_get_running_drops_notification():
client.runs.join_stream = fake_stream
client.runs.get = AsyncMock(return_value={"status": "running"})
await async_notifier.watch_run_and_notify(
client, "thr-bug", "rB", "data-analysis-agent"
_drain_all(async_notifier)
run_async(
async_notifier.watch_run_and_notify(
client, "thr-bug", "rB", "data-analysis-agent"
)
)
# No notification should have been enqueued anywhere.
@@ -813,7 +872,7 @@ async def test_watcher_clean_exit_with_runs_get_running_drops_notification():
assert client.runs.get.await_count >= 1
async def test_watcher_unknown_status_treated_as_non_terminal():
def test_watcher_unknown_status_treated_as_non_terminal(run_async):
"""Future / unrecognized status values should trigger a re-join, not a
false-positive notification.
@@ -834,7 +893,8 @@ async def test_watcher_unknown_status_treated_as_non_terminal():
side_effect=[{"status": "queued"}, {"status": "success"}]
)
await async_notifier.watch_run_and_notify(client, "thrU", "rU", "agentU")
_drain_all(async_notifier)
run_async(async_notifier.watch_run_and_notify(client, "thrU", "rU", "agentU"))
notif = async_notifier._notification_queue.get_nowait()
assert notif.status == "success"
@@ -842,7 +902,7 @@ async def test_watcher_unknown_status_treated_as_non_terminal():
assert client.runs.get.await_count == 2
async def test_watcher_runs_get_persistent_failure_drops_notification(monkeypatch):
def test_watcher_runs_get_persistent_failure_drops_notification(run_async, monkeypatch):
"""If ``runs.get`` keeps raising, the watcher cannot verify terminal
state and MUST drop the notification rather than default to
``"success"`` — otherwise a transient server outage reintroduces the
@@ -861,20 +921,22 @@ async def test_watcher_runs_get_persistent_failure_drops_notification(monkeypatc
monkeypatch.setattr(async_notifier.asyncio, "sleep", _no_sleep)
await async_notifier.watch_run_and_notify(client, "thrG", "rG", "agentG")
_drain_all(async_notifier)
run_async(async_notifier.watch_run_and_notify(client, "thrG", "rG", "agentG"))
# No notification — watcher exhausted the reconnect budget. Check every
# queue routing could send to so a future routing change can't make this
# test silently false-pass.
assert _drain_one_queue_helper(async_notifier._unrouted_queue) == []
assert _drain_one_queue_helper(async_notifier._notification_queue) == []
for q in async_notifier._notifications_by_thread.values():
assert _drain_one_queue_helper(q) == []
if hasattr(async_notifier, "_notifications_by_thread"):
for q in async_notifier._notifications_by_thread.values():
assert _drain_one_queue_helper(q) == []
# 1 initial + _MAX_RECONNECT_ATTEMPTS retries = 11 calls total.
assert client.runs.get.await_count == async_notifier._MAX_RECONNECT_ATTEMPTS + 1
async def test_watcher_runs_get_transient_failure_recovers(monkeypatch):
def test_watcher_runs_get_transient_failure_recovers(run_async, monkeypatch):
"""A single ``runs.get`` failure followed by a successful response on
retry must produce a correct notification — verifies the bounded
retry path actually recovers from transient outages instead of just
@@ -895,14 +957,15 @@ async def test_watcher_runs_get_transient_failure_recovers(monkeypatch):
monkeypatch.setattr(async_notifier.asyncio, "sleep", _no_sleep)
await async_notifier.watch_run_and_notify(client, "thrT", "rT", "agentT")
_drain_all(async_notifier)
run_async(async_notifier.watch_run_and_notify(client, "thrT", "rT", "agentT"))
notif = async_notifier._notification_queue.get_nowait()
assert notif.status == "success"
assert client.runs.get.await_count == 2
async def test_watcher_re_joins_stream_until_terminal_status():
def test_watcher_re_joins_stream_until_terminal_status(run_async):
"""When runs.get returns 'running' on attempt N but a terminal status
on attempt N+1, the watcher re-joins, observes the terminal status,
and enqueues the notification correctly."""
@@ -917,7 +980,8 @@ async def test_watcher_re_joins_stream_until_terminal_status():
side_effect=[{"status": "running"}, {"status": "success"}]
)
await async_notifier.watch_run_and_notify(client, "thrR", "rR", "agentR")
_drain_all(async_notifier)
run_async(async_notifier.watch_run_and_notify(client, "thrR", "rR", "agentR"))
notif = async_notifier._notification_queue.get_nowait()
assert notif.status == "success"
@@ -931,12 +995,15 @@ async def test_watcher_re_joins_stream_until_terminal_status():
# ============================================================================
async def test_consume_notifications_propagates_inject_exception():
def test_consume_notifications_propagates_inject_exception(run_async):
"""If the run_message callback raises, consume_notifications propagates
the exception to the caller — pollers wrap it in try/except so the
poller task does not die."""
import pytest
from EvoScientist.cli import async_notifier as an
_drain_all(an)
an._notification_queue.put(
an.AsyncTaskNotification("tX", "writing-agent", "success", "", "")
)
@@ -948,10 +1015,11 @@ async def test_consume_notifications_propagates_inject_exception():
return {}
with pytest.raises(RuntimeError, match="kaboom"):
await an.consume_notifications(boom_runner, state_reader)
run_async(an.consume_notifications(boom_runner, state_reader))
_drain_all(an)
async def test_watcher_skips_notification_on_stream_fail_with_nonterminal_status():
def test_watcher_skips_notification_on_stream_fail_with_nonterminal_status(run_async):
"""When the SSE stream errors AND runs.get returns a non-terminal status
(e.g. ``pending`` because the run is still alive), the watcher must
NOT enqueue a notification — otherwise the user sees a confusing
@@ -967,13 +1035,15 @@ async def test_watcher_skips_notification_on_stream_fail_with_nonterminal_status
client.runs.join_stream = fake_stream
client.runs.get = AsyncMock(return_value={"status": "pending"})
await async_notifier.watch_run_and_notify(client, "thrP", "rP", "agentP")
_drain_all(async_notifier)
run_async(async_notifier.watch_run_and_notify(client, "thrP", "rP", "agentP"))
# No notification should have been enqueued in any queue.
assert _drain_one_queue_helper(async_notifier._unrouted_queue) == []
assert _drain_one_queue_helper(async_notifier._notification_queue) == []
for q in async_notifier._notifications_by_thread.values():
assert _drain_one_queue_helper(q) == []
if hasattr(async_notifier, "_notifications_by_thread"):
for q in async_notifier._notifications_by_thread.values():
assert _drain_one_queue_helper(q) == []
def _drain_one_queue_helper(q):
@@ -990,6 +1060,8 @@ def test_active_watchers_grace_filters_by_thread():
(otherwise consume_notifications grace period would block thread A by up
to 3s waiting for thread B's unrelated watchers to finish)."""
async_notifier._active_watchers.clear()
# Sentinel handles — only their identity matters here, not their type
handle_a = object()
handle_b = object()
+21 -22
View File
@@ -7,6 +7,7 @@ deepagents internals. It hooks into ``awrap_tool_call`` and only fires on
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
@@ -104,7 +105,7 @@ def _make_middleware():
return mw, fake_client
async def test_middleware_spawns_watcher_on_start_async_task():
def test_middleware_spawns_watcher_on_start_async_task():
"""A successful start_async_task tool call must spawn one watcher per task."""
from langgraph.types import Command
@@ -141,7 +142,7 @@ async def test_middleware_spawns_watcher_on_start_async_task():
)
with patch.object(async_notifier, "spawn_watcher", side_effect=fake_spawn):
result = await mw.awrap_tool_call(request, fake_handler)
result = asyncio.run(mw.awrap_tool_call(request, fake_handler))
assert isinstance(result, Command)
assert spawn_calls == [
@@ -149,7 +150,7 @@ async def test_middleware_spawns_watcher_on_start_async_task():
]
async def test_middleware_spawns_watcher_on_update_async_task():
def test_middleware_spawns_watcher_on_update_async_task():
"""A successful update_async_task call must also spawn a (replacement) watcher."""
from langgraph.types import Command
@@ -182,7 +183,7 @@ async def test_middleware_spawns_watcher_on_update_async_task():
)
with patch.object(async_notifier, "spawn_watcher", side_effect=fake_spawn):
await mw.awrap_tool_call(request, fake_handler)
asyncio.run(mw.awrap_tool_call(request, fake_handler))
assert len(spawn_calls) == 1
args, kwargs = spawn_calls[0]
@@ -194,7 +195,7 @@ async def test_middleware_spawns_watcher_on_update_async_task():
assert kwargs["origin_cli_thread_id"] == "cli-thread-A"
async def test_middleware_pre_cancels_old_watcher_on_update():
def test_middleware_pre_cancels_old_watcher_on_update():
"""update_async_task must cancel the existing watcher BEFORE invoking the handler.
Otherwise the new run interrupts the old run's stream, which closes
@@ -220,14 +221,14 @@ async def test_middleware_pre_cancels_old_watcher_on_update():
try:
with patch.object(async_notifier, "spawn_watcher"):
await mw.awrap_tool_call(request, fake_handler)
asyncio.run(mw.awrap_tool_call(request, fake_handler))
finally:
async_notifier._watcher_by_thread.pop("task-1", None)
assert cancel_observed_before_handler["value"] is True
async def test_middleware_passes_through_unrelated_tools():
def test_middleware_passes_through_unrelated_tools():
"""A non-launch tool call must not spawn any watcher and must return result unchanged."""
mw, _ = _make_middleware()
@@ -239,13 +240,13 @@ async def test_middleware_passes_through_unrelated_tools():
request = _build_request("ls", {"path": "/"}, thread_id="t")
with patch.object(async_notifier, "spawn_watcher") as mock_spawn:
result = await mw.awrap_tool_call(request, fake_handler)
result = asyncio.run(mw.awrap_tool_call(request, fake_handler))
assert result is sentinel
assert mock_spawn.call_count == 0
async def test_middleware_handles_non_command_results_gracefully():
def test_middleware_handles_non_command_results_gracefully():
"""If the launch tool returns a string (validation error), no watcher is spawned."""
mw, _ = _make_middleware()
@@ -259,13 +260,13 @@ async def test_middleware_handles_non_command_results_gracefully():
)
with patch.object(async_notifier, "spawn_watcher") as mock_spawn:
result = await mw.awrap_tool_call(request, fake_handler)
result = asyncio.run(mw.awrap_tool_call(request, fake_handler))
assert result == "Unknown async subagent type `bogus`"
assert mock_spawn.call_count == 0
async def test_middleware_origin_thread_id_is_none_when_runtime_config_missing():
def test_middleware_origin_thread_id_is_none_when_runtime_config_missing():
"""When runtime.config is empty, origin_cli_thread_id must be None (not crash)."""
from langgraph.types import Command
@@ -298,12 +299,12 @@ async def test_middleware_origin_thread_id_is_none_when_runtime_config_missing()
)
with patch.object(async_notifier, "spawn_watcher", side_effect=fake_spawn):
await mw.awrap_tool_call(request, fake_handler)
asyncio.run(mw.awrap_tool_call(request, fake_handler))
assert captured.get("origin_cli_thread_id") is None
async def test_middleware_swallows_spawn_exceptions():
def test_middleware_swallows_spawn_exceptions():
"""spawn_watcher errors must not propagate up — middleware logs and continues."""
from langgraph.types import Command
@@ -335,7 +336,7 @@ async def test_middleware_swallows_spawn_exceptions():
with patch.object(async_notifier, "spawn_watcher", side_effect=boom):
# Should not raise.
result = await mw.awrap_tool_call(request, fake_handler)
result = asyncio.run(mw.awrap_tool_call(request, fake_handler))
assert isinstance(result, Command)
@@ -355,9 +356,7 @@ async def test_middleware_swallows_spawn_exceptions():
),
],
)
async def test_middleware_picks_correct_prompt_field_per_tool(
tool_name, args, prompt_field
):
def test_middleware_picks_correct_prompt_field_per_tool(tool_name, args, prompt_field):
"""start_async_task uses 'description'; update_async_task uses 'message'."""
from langgraph.types import Command
@@ -386,12 +385,12 @@ async def test_middleware_picks_correct_prompt_field_per_tool(
request = _build_request(tool_name, args, thread_id="t")
with patch.object(async_notifier, "spawn_watcher", side_effect=fake_spawn):
await mw.awrap_tool_call(request, fake_handler)
asyncio.run(mw.awrap_tool_call(request, fake_handler))
assert captured_prompt["value"] == prompt_field
async def test_middleware_prompt_field_is_tool_name_gated_not_fallback_chained():
def test_middleware_prompt_field_is_tool_name_gated_not_fallback_chained():
"""update_async_task with extra `description` arg must still use `message`.
Guards against the previous `args.get('description') or args.get('message')`
@@ -433,12 +432,12 @@ async def test_middleware_prompt_field_is_tool_name_gated_not_fallback_chained()
)
with patch.object(async_notifier, "spawn_watcher", side_effect=fake_spawn):
await mw.awrap_tool_call(request, fake_handler)
asyncio.run(mw.awrap_tool_call(request, fake_handler))
assert captured_prompt["value"] == "use this"
async def test_middleware_pre_cancel_swallows_unexpected_errors():
def test_middleware_pre_cancel_swallows_unexpected_errors():
"""A faulty old-watcher handle must not block the handler from running."""
from langgraph.types import Command
@@ -461,7 +460,7 @@ async def test_middleware_pre_cancel_swallows_unexpected_errors():
try:
with patch.object(async_notifier, "spawn_watcher"):
# Should not raise.
await mw.awrap_tool_call(request, fake_handler)
asyncio.run(mw.awrap_tool_call(request, fake_handler))
finally:
async_notifier._watcher_by_thread.pop("t1", None)
+7 -6
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
import json
from types import SimpleNamespace
@@ -916,16 +917,16 @@ class _AsyncFakeCrons:
return [{"cron_id": "cron-async"}]
async def test_alist_autoskill_schedules_uses_async_client_and_explicit_limit(
monkeypatch,
):
def test_alist_autoskill_schedules_uses_async_client_and_explicit_limit(monkeypatch):
crons = _AsyncFakeCrons()
client = SimpleNamespace(crons=crons)
monkeypatch.setattr("langgraph_sdk.get_client", lambda **_kwargs: client)
rows = await alist_autoskill_schedules(
EvoScientistConfig(),
limit=3,
rows = asyncio.run(
alist_autoskill_schedules(
EvoScientistConfig(),
limit=3,
)
)
assert rows == [{"cron_id": "cron-async"}]
+2 -2
View File
@@ -91,7 +91,7 @@ def test_stop_already_finished_is_graceful(tmp_path):
assert "already finished" in bg.stop(pid)
def test_exited_elapsed_is_frozen(tmp_path, monkeypatch):
def test_exited_elapsed_is_frozen(tmp_path):
"""Elapsed for an exited process freezes at its runtime, it must not keep growing."""
pid = bg.launch(_true_cmd(), str(tmp_path))
assert _wait_until(lambda: bg._PROCESSES[pid].finished_ts is not None)
@@ -99,7 +99,7 @@ def test_exited_elapsed_is_frozen(tmp_path, monkeypatch):
proc = bg._PROCESSES[pid]
assert proc.finished_ts is not None
first = bg._elapsed(proc)
monkeypatch.setattr(bg.time, "time", lambda: proc.finished_ts + 100.0)
time.sleep(1.1) # intentional: prove elapsed stays frozen, not ticking up
assert bg._elapsed(proc) == first
+403 -369
View File
@@ -12,6 +12,7 @@ import pytest
from EvoScientist.channels.bus.events import InboundMessage
from EvoScientist.channels.bus.message_bus import MessageBus
from EvoScientist.channels.channel_manager import ChannelManager
from tests.conftest import run_async as _run
from tests.fakes import QueueFakeChannel as FakeChannel
@@ -57,7 +58,7 @@ def clean_channel_state():
class TestBusInboundConsumer:
"""Test the _bus_inbound_consumer queue bridge."""
async def test_processes_inbound_and_publishes_outbound(self):
def test_processes_inbound_and_publishes_outbound(self):
"""InboundMessage -> queue -> response -> OutboundMessage flow."""
from EvoScientist.cli.channel import (
_bus_inbound_consumer,
@@ -67,51 +68,54 @@ class TestBusInboundConsumer:
_drain_queue(_message_queue)
bus = MessageBus()
manager = ChannelManager(bus)
ch = FakeChannel()
manager.register(ch)
async def _test():
bus = MessageBus()
manager = ChannelManager(bus)
ch = FakeChannel()
manager.register(ch)
consumer = asyncio.create_task(_bus_inbound_consumer(bus, manager))
consumer = asyncio.create_task(_bus_inbound_consumer(bus, manager))
await bus.publish_inbound(
InboundMessage(
channel="fake",
sender_id="user1",
chat_id="chat1",
content="hello agent",
await bus.publish_inbound(
InboundMessage(
channel="fake",
sender_id="user1",
chat_id="chat1",
content="hello agent",
)
)
)
# Wait for consumer to enqueue the message
for _ in range(20):
if not _message_queue.empty():
break
await asyncio.sleep(0.05)
# Wait for consumer to enqueue the message
for _ in range(20):
if not _message_queue.empty():
break
await asyncio.sleep(0.05)
msg = _message_queue.get_nowait()
assert msg.content == "hello agent"
assert msg.sender == "user1"
assert msg.channel_type == "fake"
msg = _message_queue.get_nowait()
assert msg.content == "hello agent"
assert msg.sender == "user1"
assert msg.channel_type == "fake"
# Simulate main-thread response
_set_channel_response(msg.msg_id, "Reply to: hello agent")
# Simulate main-thread response
_set_channel_response(msg.msg_id, "Reply to: hello agent")
outbound = await asyncio.wait_for(
bus.consume_outbound(),
timeout=2.0,
)
assert outbound.channel == "fake"
assert outbound.chat_id == "chat1"
assert "Reply to: hello agent" in outbound.content
outbound = await asyncio.wait_for(
bus.consume_outbound(),
timeout=2.0,
)
assert outbound.channel == "fake"
assert outbound.chat_id == "chat1"
assert "Reply to: hello agent" in outbound.content
consumer.cancel()
try:
await consumer
except asyncio.CancelledError:
pass
consumer.cancel()
try:
await consumer
except asyncio.CancelledError:
pass
async def test_no_response_fallback(self):
_run(_test())
def test_no_response_fallback(self):
"""Empty response is replaced with 'No response' fallback."""
from EvoScientist.cli.channel import (
_bus_inbound_consumer,
@@ -121,44 +125,47 @@ class TestBusInboundConsumer:
_drain_queue(_message_queue)
bus = MessageBus()
manager = ChannelManager(bus)
ch = FakeChannel()
manager.register(ch)
async def _test():
bus = MessageBus()
manager = ChannelManager(bus)
ch = FakeChannel()
manager.register(ch)
consumer = asyncio.create_task(_bus_inbound_consumer(bus, manager))
consumer = asyncio.create_task(_bus_inbound_consumer(bus, manager))
await bus.publish_inbound(
InboundMessage(
channel="fake",
sender_id="user1",
chat_id="chat1",
content="test",
await bus.publish_inbound(
InboundMessage(
channel="fake",
sender_id="user1",
chat_id="chat1",
content="test",
)
)
)
for _ in range(20):
if not _message_queue.empty():
break
await asyncio.sleep(0.05)
for _ in range(20):
if not _message_queue.empty():
break
await asyncio.sleep(0.05)
msg = _message_queue.get_nowait()
# Set empty response — falsy, so consumer falls back to "No response"
_set_channel_response(msg.msg_id, "")
msg = _message_queue.get_nowait()
# Set empty response — falsy, so consumer falls back to "No response"
_set_channel_response(msg.msg_id, "")
outbound = await asyncio.wait_for(
bus.consume_outbound(),
timeout=2.0,
)
assert outbound.content == "No response"
outbound = await asyncio.wait_for(
bus.consume_outbound(),
timeout=2.0,
)
assert outbound.content == "No response"
consumer.cancel()
try:
await consumer
except asyncio.CancelledError:
pass
consumer.cancel()
try:
await consumer
except asyncio.CancelledError:
pass
async def test_late_response_after_timeout_still_publishes(self, monkeypatch):
_run(_test())
def test_late_response_after_timeout_still_publishes(self, monkeypatch):
"""A response that arrives after the bridge timeout is still forwarded."""
from EvoScientist.cli import channel as channel_mod
from EvoScientist.cli.channel import (
@@ -172,53 +179,56 @@ class TestBusInboundConsumer:
_drain_queue(_message_queue)
bus = MessageBus()
manager = ChannelManager(bus)
ch = FakeChannel()
manager.register(ch)
async def _test():
bus = MessageBus()
manager = ChannelManager(bus)
ch = FakeChannel()
manager.register(ch)
consumer = asyncio.create_task(_bus_inbound_consumer(bus, manager))
consumer = asyncio.create_task(_bus_inbound_consumer(bus, manager))
await bus.publish_inbound(
InboundMessage(
channel="fake",
sender_id="user1",
chat_id="chat1",
content="slow request",
message_id="msg-123",
await bus.publish_inbound(
InboundMessage(
channel="fake",
sender_id="user1",
chat_id="chat1",
content="slow request",
message_id="msg-123",
)
)
)
for _ in range(20):
if not _message_queue.empty():
break
await asyncio.sleep(0.05)
for _ in range(20):
if not _message_queue.empty():
break
await asyncio.sleep(0.05)
msg = _message_queue.get_nowait()
msg = _message_queue.get_nowait()
notice = await asyncio.wait_for(
bus.consume_outbound(),
timeout=1.0,
)
assert "Still working on it" in notice.content
assert notice.reply_to == "msg-123"
notice = await asyncio.wait_for(
bus.consume_outbound(),
timeout=1.0,
)
assert "Still working on it" in notice.content
assert notice.reply_to == "msg-123"
_set_channel_response(msg.msg_id, "final answer")
_set_channel_response(msg.msg_id, "final answer")
outbound = await asyncio.wait_for(
bus.consume_outbound(),
timeout=1.0,
)
assert outbound.content == "final answer"
assert outbound.reply_to == "msg-123"
outbound = await asyncio.wait_for(
bus.consume_outbound(),
timeout=1.0,
)
assert outbound.content == "final answer"
assert outbound.reply_to == "msg-123"
consumer.cancel()
try:
await consumer
except asyncio.CancelledError:
pass
consumer.cancel()
try:
await consumer
except asyncio.CancelledError:
pass
async def test_late_timeout_keeps_active_request_cancellable(self, monkeypatch):
_run(_test())
def test_late_timeout_keeps_active_request_cancellable(self, monkeypatch):
"""Late timeout must not discard an active request's cancel scope."""
from EvoScientist.cli import channel as channel_mod
from EvoScientist.cli.channel import (
@@ -233,179 +243,191 @@ class TestBusInboundConsumer:
monkeypatch.setattr(channel_mod, "_RESPONSE_TIMEOUT", 0.05)
monkeypatch.setattr(channel_mod, "_LATE_RESPONSE_TIMEOUT", 0.05)
bus = MessageBus()
manager = ChannelManager(bus)
ch = FakeChannel()
manager.register(ch)
async def _test():
bus = MessageBus()
manager = ChannelManager(bus)
ch = FakeChannel()
manager.register(ch)
task = asyncio.create_task(
_handle_bus_message(
task = asyncio.create_task(
_handle_bus_message(
bus,
manager,
InboundMessage(
channel="fake",
sender_id="user1",
chat_id="chat1",
content="still running",
message_id="msg-active",
),
)
)
queued = None
for _ in range(20):
with _message_queue.mutex:
queued = _message_queue.queue[0] if _message_queue.queue else None
if queued is not None:
break
await asyncio.sleep(0.05)
assert queued is not None
assert _claim_channel_request(queued) is True
notice = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
assert "Still working on it" in notice.content
await task
assert _channel_request_state(queued.msg_id) == "active"
cancel_scope = _channel_message_cancel_scope(queued)
assert not display_mod.is_stream_cancel_requested(cancel_scope)
await _handle_bus_message(
bus,
manager,
InboundMessage(
channel="fake",
sender_id="user1",
chat_id="chat1",
content="still running",
message_id="msg-active",
content="/stop",
message_id="msg-stop-active",
),
)
)
queued = None
for _ in range(20):
with _message_queue.mutex:
queued = _message_queue.queue[0] if _message_queue.queue else None
if queued is not None:
break
await asyncio.sleep(0.05)
ack = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
assert ack.content == "Stopped."
assert ack.reply_to == "msg-stop-active"
assert display_mod.is_stream_cancel_requested(cancel_scope)
assert queued is not None
assert _claim_channel_request(queued) is True
_run(_test())
notice = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
assert "Still working on it" in notice.content
await task
assert _channel_request_state(queued.msg_id) == "active"
cancel_scope = _channel_message_cancel_scope(queued)
assert not display_mod.is_stream_cancel_requested(cancel_scope)
await _handle_bus_message(
bus,
manager,
InboundMessage(
channel="fake",
sender_id="user1",
chat_id="chat1",
content="/stop",
message_id="msg-stop-active",
),
)
ack = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
assert ack.content == "Stopped."
assert ack.reply_to == "msg-stop-active"
assert display_mod.is_stream_cancel_requested(cancel_scope)
async def test_cancelled_wait_cleans_pending_response(self):
def test_cancelled_wait_cleans_pending_response(self):
"""Cancelling a pending bus message should not leak its response slot."""
from EvoScientist.cli import channel as channel_mod
from EvoScientist.cli.channel import _handle_bus_message, _message_queue
bus = MessageBus()
manager = ChannelManager(bus)
ch = FakeChannel()
manager.register(ch)
async def _test():
bus = MessageBus()
manager = ChannelManager(bus)
ch = FakeChannel()
manager.register(ch)
task = asyncio.create_task(
_handle_bus_message(
bus,
manager,
InboundMessage(
channel="fake",
sender_id="user1",
chat_id="chat1",
content="cancel me",
),
task = asyncio.create_task(
_handle_bus_message(
bus,
manager,
InboundMessage(
channel="fake",
sender_id="user1",
chat_id="chat1",
content="cancel me",
),
)
)
)
for _ in range(20):
if not _message_queue.empty():
break
await asyncio.sleep(0.05)
for _ in range(20):
if not _message_queue.empty():
break
await asyncio.sleep(0.05)
queued = _message_queue.get_nowait()
with channel_mod._response_lock:
assert queued.msg_id in channel_mod._pending_responses
queued = _message_queue.get_nowait()
with channel_mod._response_lock:
assert queued.msg_id in channel_mod._pending_responses
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
with channel_mod._response_lock:
assert queued.msg_id not in channel_mod._pending_responses
with channel_mod._response_lock:
assert queued.msg_id not in channel_mod._pending_responses
async def test_consumer_shutdown_cleans_pending_response(self):
_run(_test())
def test_consumer_shutdown_cleans_pending_response(self):
"""Stopping the consumer should cancel late waits and clear state."""
from EvoScientist.cli import channel as channel_mod
from EvoScientist.cli.channel import _bus_inbound_consumer, _message_queue
bus = MessageBus()
manager = ChannelManager(bus)
ch = FakeChannel()
manager.register(ch)
async def _test():
bus = MessageBus()
manager = ChannelManager(bus)
ch = FakeChannel()
manager.register(ch)
consumer = asyncio.create_task(_bus_inbound_consumer(bus, manager))
consumer = asyncio.create_task(_bus_inbound_consumer(bus, manager))
await bus.publish_inbound(
InboundMessage(
channel="fake",
sender_id="user1",
chat_id="chat1",
content="slow shutdown",
await bus.publish_inbound(
InboundMessage(
channel="fake",
sender_id="user1",
chat_id="chat1",
content="slow shutdown",
)
)
)
for _ in range(20):
if not _message_queue.empty():
break
await asyncio.sleep(0.05)
for _ in range(20):
if not _message_queue.empty():
break
await asyncio.sleep(0.05)
queued = _message_queue.get_nowait()
with channel_mod._response_lock:
assert queued.msg_id in channel_mod._pending_responses
queued = _message_queue.get_nowait()
with channel_mod._response_lock:
assert queued.msg_id in channel_mod._pending_responses
consumer.cancel()
await consumer
consumer.cancel()
await consumer
with channel_mod._response_lock:
assert queued.msg_id not in channel_mod._pending_responses
with channel_mod._response_lock:
assert queued.msg_id not in channel_mod._pending_responses
async def test_stop_during_hitl_wait_releases_wait_and_acks(self):
_run(_test())
def test_stop_during_hitl_wait_releases_wait_and_acks(self):
"""`/stop` should wake pending HITL wait and publish immediate ack."""
from EvoScientist.cli import channel as channel_mod
from EvoScientist.cli.channel import _bus_inbound_consumer, _message_queue
bus = MessageBus()
manager = ChannelManager(bus)
ch = FakeChannel()
manager.register(ch)
async def _test():
bus = MessageBus()
manager = ChannelManager(bus)
ch = FakeChannel()
manager.register(ch)
hitl_event = channel_mod._register_hitl_wait("fake", "chat1")
consumer = asyncio.create_task(_bus_inbound_consumer(bus, manager))
hitl_event = channel_mod._register_hitl_wait("fake", "chat1")
consumer = asyncio.create_task(_bus_inbound_consumer(bus, manager))
await bus.publish_inbound(
InboundMessage(
channel="fake",
sender_id="user1",
chat_id="chat1",
content="/stop",
message_id="m-stop-1",
await bus.publish_inbound(
InboundMessage(
channel="fake",
sender_id="user1",
chat_id="chat1",
content="/stop",
message_id="m-stop-1",
)
)
)
for _ in range(20):
if hitl_event.is_set():
break
await asyncio.sleep(0.05)
assert hitl_event.is_set()
assert channel_mod._pop_hitl_reply("fake", "chat1") == "/stop"
for _ in range(20):
if hitl_event.is_set():
break
await asyncio.sleep(0.05)
assert hitl_event.is_set()
assert channel_mod._pop_hitl_reply("fake", "chat1") == "/stop"
outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=2.0)
assert outbound.content == "Stopped."
assert outbound.reply_to == "m-stop-1"
assert _message_queue.empty()
outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=2.0)
assert outbound.content == "Stopped."
assert outbound.reply_to == "m-stop-1"
assert _message_queue.empty()
consumer.cancel()
try:
await consumer
except asyncio.CancelledError:
pass
consumer.cancel()
try:
await consumer
except asyncio.CancelledError:
pass
async def test_stop_cancels_queued_request_before_main_thread_processes_it(self):
_run(_test())
def test_stop_cancels_queued_request_before_main_thread_processes_it(self):
"""`/stop` should cancel a queued request instead of only acking."""
from EvoScientist.cli import channel as channel_mod
from EvoScientist.cli.channel import (
@@ -414,70 +436,73 @@ class TestBusInboundConsumer:
_message_queue,
)
bus = MessageBus()
manager = ChannelManager(bus)
ch = FakeChannel()
manager.register(ch)
async def _test():
bus = MessageBus()
manager = ChannelManager(bus)
ch = FakeChannel()
manager.register(ch)
task = asyncio.create_task(
_handle_bus_message(
task = asyncio.create_task(
_handle_bus_message(
bus,
manager,
InboundMessage(
channel="fake",
sender_id="user1",
chat_id="chat1",
content="please work",
message_id="m-work-1",
),
)
)
queued = None
for _ in range(20):
with _message_queue.mutex:
queued = _message_queue.queue[0] if _message_queue.queue else None
if queued is not None:
break
await asyncio.sleep(0.05)
assert queued is not None
with channel_mod._response_lock:
assert queued.msg_id in channel_mod._pending_responses
await _handle_bus_message(
bus,
manager,
InboundMessage(
channel="fake",
sender_id="user1",
chat_id="chat1",
content="please work",
message_id="m-work-1",
content="/stop",
message_id="m-stop-2",
),
)
)
queued = None
for _ in range(20):
with _message_queue.mutex:
queued = _message_queue.queue[0] if _message_queue.queue else None
if queued is not None:
break
await asyncio.sleep(0.05)
with pytest.raises(asyncio.CancelledError):
await task
assert queued is not None
with channel_mod._response_lock:
assert queued.msg_id in channel_mod._pending_responses
skipped = _message_queue.get_nowait()
assert skipped.msg_id == queued.msg_id
assert _claim_or_complete_channel_request(skipped) is False
await _handle_bus_message(
bus,
manager,
InboundMessage(
channel="fake",
sender_id="user1",
chat_id="chat1",
content="/stop",
message_id="m-stop-2",
),
)
with channel_mod._response_lock:
assert queued.msg_id not in channel_mod._pending_responses
with channel_mod._channel_request_lock:
assert queued.msg_id not in channel_mod._channel_requests
assert queued.msg_id not in channel_mod._cancelled_channel_messages
assert "fake:chat1" not in channel_mod._session_requests
with pytest.raises(asyncio.CancelledError):
await task
outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=2.0)
assert outbound.content == "Stopped."
assert outbound.reply_to == "m-stop-2"
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(bus.consume_outbound(), timeout=0.2)
skipped = _message_queue.get_nowait()
assert skipped.msg_id == queued.msg_id
assert _claim_or_complete_channel_request(skipped) is False
_run(_test())
with channel_mod._response_lock:
assert queued.msg_id not in channel_mod._pending_responses
with channel_mod._channel_request_lock:
assert queued.msg_id not in channel_mod._channel_requests
assert queued.msg_id not in channel_mod._cancelled_channel_messages
assert "fake:chat1" not in channel_mod._session_requests
outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=2.0)
assert outbound.content == "Stopped."
assert outbound.reply_to == "m-stop-2"
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(bus.consume_outbound(), timeout=0.2)
async def test_stop_leaves_resolved_response_available_for_delivery(self):
def test_stop_leaves_resolved_response_available_for_delivery(self):
"""`/stop` must not steal a response whose waiter already resolved."""
from EvoScientist.cli import channel as channel_mod
from EvoScientist.cli.channel import (
@@ -490,39 +515,42 @@ class TestBusInboundConsumer:
_set_channel_response,
)
msg = ChannelMessage(
msg_id="msg-resolved",
content="already answered",
sender="user1",
channel_type="fake",
metadata={},
channel_ref=None,
bus_ref=None,
chat_id="chat1",
message_id="m-resolved",
)
async def _test():
msg = ChannelMessage(
msg_id="msg-resolved",
content="already answered",
sender="user1",
channel_type="fake",
metadata={},
channel_ref=None,
bus_ref=None,
chat_id="chat1",
message_id="m-resolved",
)
waiter = _enqueue_channel_message(msg)
assert _claim_channel_request(msg) is True
waiter = _enqueue_channel_message(msg)
assert _claim_channel_request(msg) is True
_set_channel_response(msg.msg_id, "final answer")
assert await asyncio.wait_for(asyncio.shield(waiter), timeout=1.0) == (
"final answer"
)
_set_channel_response(msg.msg_id, "final answer")
assert await asyncio.wait_for(asyncio.shield(waiter), timeout=1.0) == (
"final answer"
)
cancelled_count, active_count = _cancel_channel_session("fake", "chat1")
assert cancelled_count == 0
assert active_count == 0
cancelled_count, active_count = _cancel_channel_session("fake", "chat1")
assert cancelled_count == 0
assert active_count == 0
with channel_mod._response_lock:
assert msg.msg_id in channel_mod._pending_responses
with channel_mod._channel_request_lock:
assert msg.msg_id not in channel_mod._cancelled_channel_messages
with channel_mod._response_lock:
assert msg.msg_id in channel_mod._pending_responses
with channel_mod._channel_request_lock:
assert msg.msg_id not in channel_mod._cancelled_channel_messages
assert _pop_channel_response(msg.msg_id) == "final answer"
_complete_channel_request(msg.msg_id)
assert _pop_channel_response(msg.msg_id) == "final answer"
_complete_channel_request(msg.msg_id)
async def test_message_counting(self):
_run(_test())
def test_message_counting(self):
"""Messages are counted via record_message."""
from EvoScientist.cli.channel import (
_bus_inbound_consumer,
@@ -532,42 +560,45 @@ class TestBusInboundConsumer:
_drain_queue(_message_queue)
bus = MessageBus()
manager = ChannelManager(bus)
ch = FakeChannel()
manager.register(ch)
async def _test():
bus = MessageBus()
manager = ChannelManager(bus)
ch = FakeChannel()
manager.register(ch)
consumer = asyncio.create_task(_bus_inbound_consumer(bus, manager))
consumer = asyncio.create_task(_bus_inbound_consumer(bus, manager))
await bus.publish_inbound(
InboundMessage(
channel="fake",
sender_id="u1",
chat_id="c1",
content="test",
await bus.publish_inbound(
InboundMessage(
channel="fake",
sender_id="u1",
chat_id="c1",
content="test",
)
)
)
for _ in range(20):
if not _message_queue.empty():
break
await asyncio.sleep(0.05)
for _ in range(20):
if not _message_queue.empty():
break
await asyncio.sleep(0.05)
msg = _message_queue.get_nowait()
_set_channel_response(msg.msg_id, "ok")
msg = _message_queue.get_nowait()
_set_channel_response(msg.msg_id, "ok")
await asyncio.wait_for(bus.consume_outbound(), timeout=2.0)
await asyncio.wait_for(bus.consume_outbound(), timeout=2.0)
assert manager._message_counts["fake"]["received"] == 1
assert manager._message_counts["fake"]["sent"] == 1
assert manager._message_counts["fake"]["received"] == 1
assert manager._message_counts["fake"]["sent"] == 1
consumer.cancel()
try:
await consumer
except asyncio.CancelledError:
pass
consumer.cancel()
try:
await consumer
except asyncio.CancelledError:
pass
async def test_channel_message_carries_metadata(self):
_run(_test())
def test_channel_message_carries_metadata(self):
"""ChannelMessage carries metadata, chat_id, and message_id."""
from EvoScientist.cli.channel import (
_bus_inbound_consumer,
@@ -577,46 +608,49 @@ class TestBusInboundConsumer:
_drain_queue(_message_queue)
bus = MessageBus()
manager = ChannelManager(bus)
ch = FakeChannel()
manager.register(ch)
async def _test():
bus = MessageBus()
manager = ChannelManager(bus)
ch = FakeChannel()
manager.register(ch)
consumer = asyncio.create_task(_bus_inbound_consumer(bus, manager))
consumer = asyncio.create_task(_bus_inbound_consumer(bus, manager))
await bus.publish_inbound(
InboundMessage(
channel="fake",
sender_id="user1",
chat_id="chat1",
content="with metadata",
metadata={"key": "value"},
message_id="msg-123",
await bus.publish_inbound(
InboundMessage(
channel="fake",
sender_id="user1",
chat_id="chat1",
content="with metadata",
metadata={"key": "value"},
message_id="msg-123",
)
)
)
for _ in range(20):
if not _message_queue.empty():
break
await asyncio.sleep(0.05)
for _ in range(20):
if not _message_queue.empty():
break
await asyncio.sleep(0.05)
msg = _message_queue.get_nowait()
assert msg.content == "with metadata"
assert msg.metadata == {"key": "value"}
assert msg.chat_id == "chat1"
assert msg.message_id == "msg-123"
assert msg.channel_ref is ch
msg = _message_queue.get_nowait()
assert msg.content == "with metadata"
assert msg.metadata == {"key": "value"}
assert msg.chat_id == "chat1"
assert msg.message_id == "msg-123"
assert msg.channel_ref is ch
_set_channel_response(msg.msg_id, "done")
_set_channel_response(msg.msg_id, "done")
outbound = await asyncio.wait_for(
bus.consume_outbound(),
timeout=2.0,
)
assert outbound.reply_to == "msg-123"
outbound = await asyncio.wait_for(
bus.consume_outbound(),
timeout=2.0,
)
assert outbound.reply_to == "msg-123"
consumer.cancel()
try:
await consumer
except asyncio.CancelledError:
pass
consumer.cancel()
try:
await consumer
except asyncio.CancelledError:
pass
_run(_test())
+12 -10
View File
@@ -3,6 +3,8 @@
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from tests.conftest import run_async as _run
def _ctx():
from EvoScientist.commands.base import ChannelRuntime, CommandContext
@@ -53,7 +55,7 @@ class TestNeedsAgent:
class TestStartPath:
"""Start flow must propagate agent/thread_id globals."""
async def test_start_binds_channel_runtime(self):
def test_start_binds_channel_runtime(self):
from EvoScientist.commands.implementation.channel import ChannelCommand
ctx, _ui = _ctx()
@@ -75,11 +77,11 @@ class TestStartPath:
return_value=config,
),
):
await ChannelCommand().execute(ctx, ["telegram"])
_run(ChannelCommand().execute(ctx, ["telegram"]))
assert ctx.channel_runtime.agent is ctx.agent
assert ctx.channel_runtime.thread_id == "tid-42"
async def test_start_propagates_send_thinking(self):
def test_start_propagates_send_thinking(self):
"""send_thinking flag must reach _start_channels_bus_mode."""
from EvoScientist.commands.implementation.channel import ChannelCommand
@@ -109,14 +111,14 @@ class TestStartPath:
return_value=config,
),
):
await ChannelCommand().execute(ctx, ["telegram"])
_run(ChannelCommand().execute(ctx, ["telegram"]))
assert captured["agent"] is ctx.agent
assert captured["thread_id"] == "tid-42"
assert captured["send_thinking"] is False
class TestAddToRunningPath:
async def test_add_to_running_binds_channel_runtime(self):
def test_add_to_running_binds_channel_runtime(self):
from EvoScientist.commands.implementation.channel import ChannelCommand
ctx, _ui = _ctx()
@@ -138,11 +140,11 @@ class TestAddToRunningPath:
return_value=config,
),
):
await ChannelCommand().execute(ctx, ["discord"])
_run(ChannelCommand().execute(ctx, ["discord"]))
assert ctx.channel_runtime.agent is ctx.agent
assert ctx.channel_runtime.thread_id == "tid-42"
async def test_add_to_running_propagates_send_thinking(self):
def test_add_to_running_propagates_send_thinking(self):
"""Adding to a running bus must honor config.channel_send_thinking."""
from EvoScientist.commands.implementation.channel import ChannelCommand
@@ -171,13 +173,13 @@ class TestAddToRunningPath:
return_value=config,
),
):
await ChannelCommand().execute(ctx, ["discord"])
_run(ChannelCommand().execute(ctx, ["discord"]))
assert captured["channel_type"] == "discord"
assert captured["send_thinking"] is True
class TestStatusPath:
async def test_status_without_running_channels(self):
def test_status_without_running_channels(self):
from EvoScientist.commands.implementation.channel import ChannelCommand
ctx, ui = _ctx()
@@ -196,6 +198,6 @@ class TestStatusPath:
return_value=config,
),
):
await ChannelCommand().execute(ctx, ["status"])
_run(ChannelCommand().execute(ctx, ["status"]))
msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("No messaging channels" in m for m in msgs)
+9 -8
View File
@@ -8,6 +8,7 @@ import pytest
from EvoScientist.commands.channel_ui import ChannelCommandUI
from EvoScientist.gateway import ThreadStore
from tests.conftest import run_async as _run
from tests.fakes import FakeGraphGateway, FakeThreadStore
@@ -56,7 +57,7 @@ def _sent_text(bus_ref) -> str:
)
async def test_handle_session_resume_sends_history_back_to_channel_without_local_duplicate():
def test_handle_session_resume_sends_history_back_to_channel_without_local_duplicate():
callback = AsyncMock()
bus_ref = SimpleNamespace(publish_outbound=AsyncMock())
@@ -71,7 +72,7 @@ async def test_handle_session_resume_sends_history_back_to_channel_without_local
thread_store=thread_store,
)
await _run_resume(ui, "thread-42", "/workspace")
_run(_run_resume(ui, "thread-42", "/workspace"))
callback.assert_awaited_once_with("thread-42", "/workspace")
assert thread_store.calls == [("get_thread_messages", "thread-42")]
@@ -83,7 +84,7 @@ async def test_handle_session_resume_sends_history_back_to_channel_without_local
assert "EvoScientist: Here is the saved answer." in text
async def test_handle_session_resume_propagates_callback_abort_without_history():
def test_handle_session_resume_propagates_callback_abort_without_history():
callback = AsyncMock(side_effect=RuntimeError("workspace conflict"))
bus_ref = SimpleNamespace(publish_outbound=AsyncMock())
thread_store = FakeThreadStore()
@@ -94,7 +95,7 @@ async def test_handle_session_resume_propagates_callback_abort_without_history()
)
with pytest.raises(RuntimeError, match="workspace conflict"):
await _run_resume(ui, "thread-42", "/workspace")
_run(_run_resume(ui, "thread-42", "/workspace"))
callback.assert_awaited_once_with("thread-42", "/workspace")
assert thread_store.calls == []
@@ -102,7 +103,7 @@ async def test_handle_session_resume_propagates_callback_abort_without_history()
assert captured == []
async def test_handle_session_resume_reports_history_load_error():
def test_handle_session_resume_reports_history_load_error():
callback = AsyncMock()
bus_ref = SimpleNamespace(publish_outbound=AsyncMock())
ui, captured = _make_ui(
@@ -113,7 +114,7 @@ async def test_handle_session_resume_reports_history_load_error():
),
)
await _run_resume(ui, "thread-42", "/workspace")
_run(_run_resume(ui, "thread-42", "/workspace"))
callback.assert_awaited_once_with("thread-42", "/workspace")
assert captured == []
@@ -122,7 +123,7 @@ async def test_handle_session_resume_reports_history_load_error():
assert "history unavailable: db locked" in text
async def test_handle_session_resume_distinguishes_non_displayable_messages():
def test_handle_session_resume_distinguishes_non_displayable_messages():
bus_ref = SimpleNamespace(publish_outbound=AsyncMock())
ui, captured = _make_ui(
bus_ref=bus_ref,
@@ -131,7 +132,7 @@ async def test_handle_session_resume_distinguishes_non_displayable_messages():
),
)
await _run_resume(ui, "thread-42", "/workspace")
_run(_run_resume(ui, "thread-42", "/workspace"))
assert captured == [
"Resumed session: thread-42\nNo displayable messages in this session."
File diff suppressed because it is too large Load Diff
+58 -27
View File
@@ -11,6 +11,8 @@ from EvoScientist.channels.debug import (
emit_debug_event_if,
)
from .conftest import run_async
def test_debug_trace_enabled_from_bool():
assert debug_trace_enabled(True) is True
@@ -73,10 +75,10 @@ def _make_channel_context(*, debug_trace=True, name="test_channel"):
return {"channel": channel}
async def test_middleware_dedup_emits_structured_event(caplog):
def test_middleware_dedup_emits_structured_event(caplog):
from EvoScientist.channels.middleware import DedupMiddleware
with caplog.at_level(logging.DEBUG):
async def _run():
mw = DedupMiddleware()
ctx = _make_channel_context()
raw = _make_raw(message_id="dup1")
@@ -89,40 +91,49 @@ async def test_middleware_dedup_emits_structured_event(caplog):
caplog.clear()
result = await mw.process_inbound(raw, ctx)
assert result is None
with caplog.at_level(logging.DEBUG):
run_async(_run())
assert "middleware_dedup_drop" in caplog.text
assert "message_id=dup1" in caplog.text
async def test_middleware_allowlist_emits_structured_event(caplog):
def test_middleware_allowlist_emits_structured_event(caplog):
from EvoScientist.channels.middleware import AllowListMiddleware
with caplog.at_level(logging.DEBUG):
async def _run():
mw = AllowListMiddleware(allowed_senders={"allowed_user"})
ctx = _make_channel_context()
raw = _make_raw(sender_id="blocked_user")
result = await mw.process_inbound(raw, ctx)
assert result is None
with caplog.at_level(logging.DEBUG):
run_async(_run())
assert "middleware_allowlist_drop" in caplog.text
assert "reason=sender_not_allowed" in caplog.text
async def test_middleware_mention_gating_emits_structured_event(caplog):
def test_middleware_mention_gating_emits_structured_event(caplog):
from EvoScientist.channels.middleware import MentionGatingMiddleware
with caplog.at_level(logging.DEBUG):
async def _run():
mw = MentionGatingMiddleware(require_mention="group")
ctx = _make_channel_context()
raw = _make_raw(is_group=True, was_mentioned=False)
result = await mw.process_inbound(raw, ctx)
assert result is None
with caplog.at_level(logging.DEBUG):
run_async(_run())
assert "middleware_mention_drop" in caplog.text
assert "policy=group" in caplog.text
async def test_typing_manager_emits_trace_events(caplog):
def test_typing_manager_emits_trace_events(caplog):
from EvoScientist.channels.middleware import TypingManager
with caplog.at_level(logging.DEBUG):
async def _run():
send_action = AsyncMock(side_effect=RuntimeError("typing api down"))
mgr = TypingManager(
send_action,
@@ -133,14 +144,17 @@ async def test_typing_manager_emits_trace_events(caplog):
await mgr.start("chat1")
await asyncio.sleep(0.01)
await mgr.stop("chat1")
with caplog.at_level(logging.DEBUG):
run_async(_run())
assert "typing_error" in caplog.text
assert "chat_id=chat1" in caplog.text
async def test_ack_reaction_emits_error_traces(caplog):
def test_ack_reaction_emits_error_traces(caplog):
from EvoScientist.channels.middleware import AckReactionMiddleware
with caplog.at_level(logging.DEBUG):
async def _run():
send_fn = AsyncMock()
remove_fn = AsyncMock(side_effect=RuntimeError("remove failed"))
ack = AckReactionMiddleware(
@@ -164,13 +178,16 @@ async def test_ack_reaction_emits_error_traces(caplog):
send_fn.reset_mock()
send_fn.side_effect = RuntimeError("api down")
await ack.send_ack("chat2", "msg2")
with caplog.at_level(logging.DEBUG):
run_async(_run())
assert "ack_send_error" in caplog.text
assert "ack_remove_error" in caplog.text
assert "api down" in caplog.text
assert "remove failed" in caplog.text
async def test_inbound_raw_event_emitted(caplog):
def test_inbound_raw_event_emitted(caplog):
"""Integration-style: _enqueue_raw emits inbound_raw at the top."""
from EvoScientist.channels.base import Channel, RawIncoming
@@ -201,16 +218,19 @@ async def test_inbound_raw_event_emitted(caplog):
config.ack_scope = "off"
config.dedup_ttl = 3600
with caplog.at_level(logging.DEBUG):
async def _run():
with patch.object(Channel, "__abstractmethods__", set()):
ch = _TestChannel(config)
raw = RawIncoming(sender_id="u1", chat_id="c1", text="hi", message_id="m1")
await ch._enqueue_raw(raw)
with caplog.at_level(logging.DEBUG):
run_async(_run())
assert "inbound_raw" in caplog.text
assert "sender_id=u1" in caplog.text
async def test_format_fallback_emits_event(caplog):
def test_format_fallback_emits_event(caplog):
"""_send_with_format_fallback emits outbound_format_fallback on fallback."""
from EvoScientist.channels.base import Channel
@@ -248,10 +268,13 @@ async def test_format_fallback_emits_event(caplog):
if call_count == 1:
raise ValueError("parse error in formatted text")
with caplog.at_level(logging.DEBUG):
async def _run():
with patch.object(Channel, "__abstractmethods__", set()):
ch = _TestChannel(config)
await ch._send_with_format_fallback(_failing_send, "<b>hi</b>", "hi")
with caplog.at_level(logging.DEBUG):
run_async(_run())
assert "outbound_format_fallback" in caplog.text
assert call_count == 2
@@ -281,7 +304,7 @@ def test_trace_mixin_trace_event(caplog):
assert "key=val" in caplog.text
async def test_standalone_dispatcher_treats_false_send_as_error(caplog):
def test_standalone_dispatcher_treats_false_send_as_error(caplog):
from EvoScientist.channels.bus import MessageBus
from EvoScientist.channels.bus.events import OutboundMessage
from EvoScientist.channels.standalone import standalone_outbound_dispatcher
@@ -292,7 +315,7 @@ async def test_standalone_dispatcher_treats_false_send_as_error(caplog):
channel.send = AsyncMock(return_value=False)
bus = MessageBus()
with caplog.at_level(logging.DEBUG):
async def _run():
task = asyncio.create_task(standalone_outbound_dispatcher(bus, channel))
await bus.publish_outbound(
OutboundMessage(channel="test", chat_id="c1", content="hi")
@@ -303,11 +326,14 @@ async def test_standalone_dispatcher_treats_false_send_as_error(caplog):
await task
except asyncio.CancelledError:
pass
with caplog.at_level(logging.DEBUG):
run_async(_run())
assert "standalone_dispatch_error" in caplog.text
assert "send() returned False" in caplog.text
async def test_standalone_dispatcher_sends_media():
def test_standalone_dispatcher_sends_media():
from EvoScientist.channels.bus import MessageBus
from EvoScientist.channels.bus.events import OutboundMessage
from EvoScientist.channels.standalone import standalone_outbound_dispatcher
@@ -319,16 +345,21 @@ async def test_standalone_dispatcher_sends_media():
channel.send_media = AsyncMock(return_value=True)
bus = MessageBus()
task = asyncio.create_task(standalone_outbound_dispatcher(bus, channel))
await bus.publish_outbound(
OutboundMessage(channel="test", chat_id="c1", content="", media=["/tmp/a.png"])
)
await asyncio.sleep(0.05)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
async def _run():
task = asyncio.create_task(standalone_outbound_dispatcher(bus, channel))
await bus.publish_outbound(
OutboundMessage(
channel="test", chat_id="c1", content="", media=["/tmp/a.png"]
)
)
await asyncio.sleep(0.05)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
run_async(_run())
channel.send_media.assert_awaited_once_with(
recipient="c1",
file_path="/tmp/a.png",
+186 -151
View File
@@ -14,6 +14,7 @@ from EvoScientist.cli.channel import (
from EvoScientist.cli.channel import (
dispatch_channel_slash_command as _dispatch_channel_slash_command,
)
from tests.conftest import run_async as _run
from tests.fakes import FakeGraphGateway, FakeThreadStore
@@ -42,31 +43,12 @@ def _make_msg(
)
async def test_non_slash_returns_false():
def test_non_slash_returns_false():
"""Plain text messages must fall through to the agent."""
msg = _make_msg(content="hello agent")
append = MagicMock()
handled = await dispatch_channel_slash_command(
msg,
agent=None,
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=append,
)
assert handled is False
append.assert_not_called()
async def test_unresolved_slash_returns_false():
"""Unknown slash commands must fall through (matches TUI behavior)."""
msg = _make_msg(content="/unknown-cmd")
append = MagicMock()
with patch(
"EvoScientist.commands.manager.manager.resolve",
return_value=None,
):
handled = await dispatch_channel_slash_command(
handled = _run(
dispatch_channel_slash_command(
msg,
agent=None,
thread_id="t1",
@@ -74,10 +56,33 @@ async def test_unresolved_slash_returns_false():
checkpointer=None,
append_system=append,
)
)
assert handled is False
append.assert_not_called()
def test_unresolved_slash_returns_false():
"""Unknown slash commands must fall through (matches TUI behavior)."""
msg = _make_msg(content="/unknown-cmd")
append = MagicMock()
with patch(
"EvoScientist.commands.manager.manager.resolve",
return_value=None,
):
handled = _run(
dispatch_channel_slash_command(
msg,
agent=None,
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=append,
)
)
assert handled is False
async def test_successful_slash_execution_sets_response_and_breadcrumb():
def test_successful_slash_execution_sets_response_and_breadcrumb():
"""Known slash command: cmd_manager.execute ran, helper returns True,
sends a confirmation to the channel user, and appends a local log line."""
msg = _make_msg()
@@ -95,13 +100,15 @@ async def test_successful_slash_execution_sets_response_and_breadcrumb():
) as mock_execute,
patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp,
):
handled = await dispatch_channel_slash_command(
msg,
agent="fake-agent",
thread_id="t1",
workspace_dir="/tmp",
checkpointer=None,
append_system=append,
handled = _run(
dispatch_channel_slash_command(
msg,
agent="fake-agent",
thread_id="t1",
workspace_dir="/tmp",
checkpointer=None,
append_system=append,
)
)
assert handled is True
mock_execute.assert_awaited_once()
@@ -112,7 +119,7 @@ async def test_successful_slash_execution_sets_response_and_breadcrumb():
assert any("Executed command from" in t for t in breadcrumbs)
async def test_slash_dispatch_passes_graph_gateway_to_command_context():
def test_slash_dispatch_passes_graph_gateway_to_command_context():
msg = _make_msg()
fake_cmd = MagicMock()
fake_cmd.needs_agent.return_value = False
@@ -135,21 +142,23 @@ async def test_slash_dispatch_passes_graph_gateway_to_command_context():
),
patch("EvoScientist.cli.channel._set_channel_response"),
):
handled = await dispatch_channel_slash_command(
msg,
agent="fake-agent",
thread_id="t1",
workspace_dir="/tmp",
checkpointer=None,
append_system=append,
graph_gateway=graph_gateway,
handled = _run(
dispatch_channel_slash_command(
msg,
agent="fake-agent",
thread_id="t1",
workspace_dir="/tmp",
checkpointer=None,
append_system=append,
graph_gateway=graph_gateway,
)
)
assert handled is True
assert captured["graph_gateway"] is graph_gateway
async def test_needs_agent_awaits_loader_and_passes_result():
def test_needs_agent_awaits_loader_and_passes_result():
"""Commands with needs_agent=True must await the loader and the
resulting agent must flow through the CommandContext."""
msg = _make_msg()
@@ -173,14 +182,16 @@ async def test_needs_agent_awaits_loader_and_passes_result():
) as mock_execute,
patch("EvoScientist.cli.channel._set_channel_response"),
):
handled = await dispatch_channel_slash_command(
msg,
agent=None,
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=append,
await_agent_ready=_await_ready,
handled = _run(
dispatch_channel_slash_command(
msg,
agent=None,
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=append,
await_agent_ready=_await_ready,
)
)
assert handled is True
await_called.assert_called_once()
@@ -190,7 +201,7 @@ async def test_needs_agent_awaits_loader_and_passes_result():
assert ctx_arg.agent == "ready-agent"
async def test_await_agent_ready_failure_sets_error_response():
def test_await_agent_ready_failure_sets_error_response():
msg = _make_msg()
fake_cmd = MagicMock()
fake_cmd.needs_agent.return_value = True
@@ -206,14 +217,16 @@ async def test_await_agent_ready_failure_sets_error_response():
),
patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp,
):
handled = await dispatch_channel_slash_command(
msg,
agent=None,
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=append,
await_agent_ready=_await_ready,
handled = _run(
dispatch_channel_slash_command(
msg,
agent=None,
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=append,
await_agent_ready=_await_ready,
)
)
assert handled is True
mock_set_resp.assert_called_once()
@@ -222,7 +235,7 @@ async def test_await_agent_ready_failure_sets_error_response():
assert "agent blew up" in resp_text
async def test_cmd_manager_raises_returns_true_with_error():
def test_cmd_manager_raises_returns_true_with_error():
"""If cmd_manager.execute raises past its own try/except, the helper
must absorb it, return True, and report via _set_channel_response."""
msg = _make_msg()
@@ -240,13 +253,15 @@ async def test_cmd_manager_raises_returns_true_with_error():
),
patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp,
):
handled = await dispatch_channel_slash_command(
msg,
agent=None,
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=append,
handled = _run(
dispatch_channel_slash_command(
msg,
agent=None,
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=append,
)
)
assert handled is True
mock_set_resp.assert_called_once()
@@ -255,7 +270,7 @@ async def test_cmd_manager_raises_returns_true_with_error():
assert "boom" in resp_text
async def test_on_cmd_completed_awaited_with_ctx_original_agent_and_cmd():
def test_on_cmd_completed_awaited_with_ctx_original_agent_and_cmd():
"""After a successful slash execute, the on_cmd_completed hook must
be awaited with (ctx, original_agent, cmd) so Rich CLI can adopt an
``/model`` agent swap and refresh status for state-mutating commands."""
@@ -287,14 +302,16 @@ async def test_on_cmd_completed_awaited_with_ctx_original_agent_and_cmd():
),
patch("EvoScientist.cli.channel._set_channel_response"),
):
handled = await dispatch_channel_slash_command(
msg,
agent="original-agent",
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=append,
on_cmd_completed=_on_completed,
handled = _run(
dispatch_channel_slash_command(
msg,
agent="original-agent",
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=append,
on_cmd_completed=_on_completed,
)
)
assert handled is True
assert captured["ctx_agent"] == "swapped-agent"
@@ -302,7 +319,7 @@ async def test_on_cmd_completed_awaited_with_ctx_original_agent_and_cmd():
assert captured["cmd_name"] == "/model"
async def test_on_cmd_completed_receives_cmd_for_new_and_compact():
def test_on_cmd_completed_receives_cmd_for_new_and_compact():
"""``/new`` / ``/compact`` invoked via channel must flow the cmd into
the hook so the callback can still refresh status when the agent
didn't swap — mirrors REPL ``interactive.py:1027-1030``."""
@@ -326,19 +343,21 @@ async def test_on_cmd_completed_receives_cmd_for_new_and_compact():
),
patch("EvoScientist.cli.channel._set_channel_response"),
):
await dispatch_channel_slash_command(
_make_msg(content=cmd_name),
agent="same-agent",
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=MagicMock(),
on_cmd_completed=_on_completed,
_run(
dispatch_channel_slash_command(
_make_msg(content=cmd_name),
agent="same-agent",
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=MagicMock(),
on_cmd_completed=_on_completed,
)
)
assert captured["cmd_name"] == cmd_name, cmd_name
async def test_on_cmd_completed_skipped_on_fall_through_and_error():
def test_on_cmd_completed_skipped_on_fall_through_and_error():
"""The hook must NOT fire for unresolved slash, non-slash text, or
when cmd_manager.execute raised."""
fake_cmd = MagicMock()
@@ -350,14 +369,16 @@ async def test_on_cmd_completed_skipped_on_fall_through_and_error():
# Non-slash
with patch("EvoScientist.cli.channel._set_channel_response"):
await dispatch_channel_slash_command(
_make_msg(content="hi"),
agent=None,
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=MagicMock(),
on_cmd_completed=_noop,
_run(
dispatch_channel_slash_command(
_make_msg(content="hi"),
agent=None,
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=MagicMock(),
on_cmd_completed=_noop,
)
)
# Unresolved slash
with (
@@ -367,14 +388,16 @@ async def test_on_cmd_completed_skipped_on_fall_through_and_error():
),
patch("EvoScientist.cli.channel._set_channel_response"),
):
await dispatch_channel_slash_command(
_make_msg(content="/nope"),
agent=None,
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=MagicMock(),
on_cmd_completed=_noop,
_run(
dispatch_channel_slash_command(
_make_msg(content="/nope"),
agent=None,
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=MagicMock(),
on_cmd_completed=_noop,
)
)
# Execute raises
with (
@@ -388,20 +411,22 @@ async def test_on_cmd_completed_skipped_on_fall_through_and_error():
),
patch("EvoScientist.cli.channel._set_channel_response"),
):
await dispatch_channel_slash_command(
_make_msg(),
agent=None,
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=MagicMock(),
on_cmd_completed=_noop,
_run(
dispatch_channel_slash_command(
_make_msg(),
agent=None,
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=MagicMock(),
on_cmd_completed=_noop,
)
)
completed.assert_not_called()
async def test_command_error_skips_completion_hook_and_reports_error():
def test_command_error_skips_completion_hook_and_reports_error():
"""A command caught as failed by CommandManager must not look successful."""
msg = _make_msg(content="/resume abc")
fake_cmd = MagicMock()
@@ -425,14 +450,16 @@ async def test_command_error_skips_completion_hook_and_reports_error():
),
patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp,
):
handled = await dispatch_channel_slash_command(
msg,
agent=None,
thread_id="old-thread",
workspace_dir="/old-workspace",
checkpointer=None,
append_system=MagicMock(),
on_cmd_completed=completed,
handled = _run(
dispatch_channel_slash_command(
msg,
agent=None,
thread_id="old-thread",
workspace_dir="/old-workspace",
checkpointer=None,
append_system=MagicMock(),
on_cmd_completed=completed,
)
)
assert handled is True
@@ -440,7 +467,7 @@ async def test_command_error_skips_completion_hook_and_reports_error():
mock_set_resp.assert_called_once_with("msg-1", "Command error: workspace conflict")
async def test_empty_command_error_still_reports_error():
def test_empty_command_error_still_reports_error():
"""An empty string error is still a command failure sentinel."""
msg = _make_msg(content="/resume abc")
fake_cmd = MagicMock()
@@ -462,14 +489,16 @@ async def test_empty_command_error_still_reports_error():
),
patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp,
):
handled = await dispatch_channel_slash_command(
msg,
agent=None,
thread_id="old-thread",
workspace_dir="/old-workspace",
checkpointer=None,
append_system=MagicMock(),
on_cmd_completed=completed,
handled = _run(
dispatch_channel_slash_command(
msg,
agent=None,
thread_id="old-thread",
workspace_dir="/old-workspace",
checkpointer=None,
append_system=MagicMock(),
on_cmd_completed=completed,
)
)
assert handled is True
@@ -477,7 +506,7 @@ async def test_empty_command_error_still_reports_error():
mock_set_resp.assert_called_once_with("msg-1", "Command error: (no details)")
async def test_on_cmd_completed_exception_is_absorbed():
def test_on_cmd_completed_exception_is_absorbed():
"""A raising hook must NOT prevent the channel response from being set."""
msg = _make_msg()
fake_cmd = MagicMock()
@@ -497,21 +526,23 @@ async def test_on_cmd_completed_exception_is_absorbed():
),
patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp,
):
handled = await dispatch_channel_slash_command(
msg,
agent="orig",
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=MagicMock(),
on_cmd_completed=_boom,
handled = _run(
dispatch_channel_slash_command(
msg,
agent="orig",
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=MagicMock(),
on_cmd_completed=_boom,
)
)
assert handled is True
mock_set_resp.assert_called_once()
assert "Command executed" in mock_set_resp.call_args[0][1]
async def test_top_level_exception_is_absorbed():
def test_top_level_exception_is_absorbed():
"""Last-ditch safety net: if anything inside the dispatch pipeline
raises unexpectedly (lazy import failure, ChannelCommandUI ctor,
terminal I/O from append_system, ...), the helper must NOT
@@ -526,13 +557,15 @@ async def test_top_level_exception_is_absorbed():
),
patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp,
):
handled = await dispatch_channel_slash_command(
msg,
agent=None,
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=MagicMock(),
handled = _run(
dispatch_channel_slash_command(
msg,
agent=None,
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=MagicMock(),
)
)
assert handled is True
mock_set_resp.assert_called_once()
@@ -541,7 +574,7 @@ async def test_top_level_exception_is_absorbed():
assert "exploded during resolve" in resp_text
async def test_cmd_execute_returning_false_falls_through():
def test_cmd_execute_returning_false_falls_through():
"""When cmd_manager.execute returns False (empty/unparseable input),
the helper must return False so the caller falls through to the agent."""
msg = _make_msg(content="/")
@@ -559,13 +592,15 @@ async def test_cmd_execute_returning_false_falls_through():
),
patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp,
):
handled = await dispatch_channel_slash_command(
msg,
agent=None,
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=append,
handled = _run(
dispatch_channel_slash_command(
msg,
agent=None,
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=append,
)
)
assert handled is False
mock_set_resp.assert_not_called()
+7 -4
View File
@@ -1,5 +1,6 @@
"""Tests for CLI interactive UI backend dispatch."""
import asyncio
from types import SimpleNamespace
import pytest
@@ -100,7 +101,7 @@ def test_background_agent_server_starts_even_when_async_subagents_disabled(
assert calls == [(config, "/tmp/workspace")]
async def test_resume_workspace_sync_runs_even_when_async_subagents_disabled(
def test_resume_workspace_sync_runs_even_when_async_subagents_disabled(
monkeypatch,
):
import EvoScientist.cli.commands as cmds
@@ -116,9 +117,11 @@ async def test_resume_workspace_sync_runs_even_when_async_subagents_disabled(
)
config = SimpleNamespace(enable_async_subagents=False)
await cmds._sync_background_agent_server_workspace(
config,
workspace_dir="/tmp/resumed-workspace",
asyncio.run(
cmds._sync_background_agent_server_workspace(
config,
workspace_dir="/tmp/resumed-workspace",
)
)
assert calls == [(config, "/tmp/resumed-workspace")]
+27 -24
View File
@@ -4,12 +4,13 @@ from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from EvoScientist.gateway import GraphTarget
from tests.conftest import run_async as _run
from tests.fakes import FakeCommandUI, FakeGraphGateway
_TARGET = GraphTarget()
async def _compact(
def _compact(
graph_gateway: FakeGraphGateway,
*,
thread_id: str = "tid-1",
@@ -17,28 +18,30 @@ async def _compact(
):
from EvoScientist.cli.commands import compact_conversation
return await compact_conversation(
graph_gateway=graph_gateway,
thread_id=thread_id,
target=_TARGET,
input_tokens_hint=input_tokens_hint,
return _run(
compact_conversation(
graph_gateway=graph_gateway,
thread_id=thread_id,
target=_TARGET,
input_tokens_hint=input_tokens_hint,
)
)
class TestCompactGuards:
"""Guard conditions that return early without touching the middleware."""
async def test_empty_messages(self):
def test_empty_messages(self):
graph_gateway = FakeGraphGateway(state_values={"messages": []})
result = await _compact(graph_gateway)
result = _compact(graph_gateway)
assert result.status == "noop"
assert "no messages" in result.message
async def test_state_read_failure(self):
def test_state_read_failure(self):
graph_gateway = FakeGraphGateway(state_error=RuntimeError("DB gone"))
result = await _compact(graph_gateway)
result = _compact(graph_gateway)
assert result.status == "error"
assert "Failed to read state" in result.message
@@ -46,7 +49,7 @@ class TestCompactGuards:
class TestCompactCutoffZero:
"""When cutoff == 0, conversation is within retention budget."""
async def test_nothing_to_compact_short_conversation(self):
def test_nothing_to_compact_short_conversation(self):
msgs = [MagicMock() for _ in range(3)]
graph_gateway = FakeGraphGateway(state_values={"messages": msgs})
@@ -76,7 +79,7 @@ class TestCompactCutoffZero:
return_value=500,
),
):
result = await _compact(graph_gateway)
result = _compact(graph_gateway)
assert result.status == "noop"
assert "within the retention budget" in result.message
@@ -86,7 +89,7 @@ class TestCompactCutoffZero:
class TestCompactNegligibleSavings:
"""When cutoff > 0 but savings are too small to be worth it."""
async def test_skip_when_few_messages_and_low_tokens(self):
def test_skip_when_few_messages_and_low_tokens(self):
msgs = [MagicMock() for _ in range(15)]
graph_gateway = FakeGraphGateway(
state_values={"messages": msgs, "_summarization_event": None}
@@ -123,14 +126,14 @@ class TestCompactNegligibleSavings:
side_effect=lambda x: next(token_values),
),
):
result = await _compact(graph_gateway)
result = _compact(graph_gateway)
assert result.status == "noop"
assert "not worth" in result.message
# No LLM call should have been made
mock_middleware_inst._acreate_summary.assert_not_called()
async def test_still_compacts_when_few_messages_but_high_tokens(self):
def test_still_compacts_when_few_messages_but_high_tokens(self):
"""2 messages but they account for >2% of tokens — should compact."""
from langchain_core.messages import HumanMessage
@@ -175,7 +178,7 @@ class TestCompactNegligibleSavings:
side_effect=lambda x: next(token_values),
),
):
result = await _compact(graph_gateway)
result = _compact(graph_gateway)
assert result.status == "ok"
assert len(graph_gateway.updated_states) == 1
@@ -184,7 +187,7 @@ class TestCompactNegligibleSavings:
class TestCompactSuccess:
"""Normal compaction flow."""
async def test_manual_threshold_blocks_low_context_compaction(self):
def test_manual_threshold_blocks_low_context_compaction(self):
msgs = [MagicMock() for _ in range(20)]
graph_gateway = FakeGraphGateway(
state_values={"messages": msgs, "_summarization_event": None}
@@ -214,7 +217,7 @@ class TestCompactSuccess:
return_value=30_000,
),
):
result = await _compact(graph_gateway)
result = _compact(graph_gateway)
assert result.status == "noop"
assert "40%" in result.message
@@ -222,7 +225,7 @@ class TestCompactSuccess:
mock_middleware_inst._determine_cutoff_index.assert_not_called()
mock_middleware_inst._acreate_summary.assert_not_called()
async def test_successful_compaction(self):
def test_successful_compaction(self):
from langchain_core.messages import HumanMessage
msgs = [MagicMock() for _ in range(20)]
@@ -270,7 +273,7 @@ class TestCompactSuccess:
side_effect=lambda x: next(token_values),
),
):
result = await _compact(graph_gateway)
result = _compact(graph_gateway)
assert result.status == "ok"
assert result.messages_compacted == 15
@@ -288,7 +291,7 @@ class TestCompactSuccess:
assert "_summarization_event" in event_data
assert event_data["_summarization_event"]["cutoff_index"] == 15
async def test_offload_failure_non_fatal(self):
def test_offload_failure_non_fatal(self):
"""Offload failure should not prevent compaction."""
from langchain_core.messages import HumanMessage
@@ -332,7 +335,7 @@ class TestCompactSuccess:
return_value=1000,
),
):
result = await _compact(graph_gateway)
result = _compact(graph_gateway)
assert result.status == "ok"
assert len(graph_gateway.updated_states) == 1
@@ -374,7 +377,7 @@ class TestRenderCompactResult:
class TestCompactCommandUI:
"""TUI-specific compact progress indicator behavior."""
async def test_command_uses_tui_indicator_when_available(self):
def test_command_uses_tui_indicator_when_available(self):
from EvoScientist.cli.commands import CompactResult
from EvoScientist.commands.base import CommandContext
from EvoScientist.commands.implementation.session import CompactCommand
@@ -410,7 +413,7 @@ class TestCompactCommandUI:
return_value="summary-panel",
),
):
await CompactCommand().execute(ctx, [])
_run(CompactCommand().execute(ctx, []))
assert ui.started == 1
assert ui.stopped == 1
-39
View File
@@ -52,8 +52,6 @@ def _restore_dangerous_env():
def temp_config_dir(tmp_path, monkeypatch):
"""Use a temporary directory for config during tests."""
config_dir = tmp_path / "evoscientist"
monkeypatch.delenv("EVOSCIENTIST_CONFIG_DIR", raising=False)
monkeypatch.delenv("EVOSCIENTIST_HOME", raising=False)
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
# Prevent load_dotenv from loading the project's real .env file
monkeypatch.setattr(
@@ -201,34 +199,14 @@ class TestEvoScientistConfig:
class TestConfigPaths:
def test_get_config_dir_with_explicit_override(self, monkeypatch, tmp_path):
"""An explicit config directory has the highest priority."""
config_dir = tmp_path / "gateway-config"
monkeypatch.setenv("EVOSCIENTIST_CONFIG_DIR", str(config_dir))
monkeypatch.setenv("EVOSCIENTIST_HOME", str(tmp_path / "runtime-home"))
assert get_config_dir() == config_dir.resolve()
def test_get_config_dir_with_evoscientist_home(self, monkeypatch, tmp_path):
"""Runtime home keeps configuration and data under one root."""
home = tmp_path / "runtime-home"
monkeypatch.delenv("EVOSCIENTIST_CONFIG_DIR", raising=False)
monkeypatch.setenv("EVOSCIENTIST_HOME", str(home))
assert get_config_dir() == home.resolve() / "config"
def test_get_config_dir_with_xdg(self, monkeypatch, tmp_path):
"""Test config dir uses XDG_CONFIG_HOME when set."""
monkeypatch.delenv("EVOSCIENTIST_CONFIG_DIR", raising=False)
monkeypatch.delenv("EVOSCIENTIST_HOME", raising=False)
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
config_dir = get_config_dir()
assert config_dir == tmp_path / "evoscientist"
def test_get_config_dir_default(self, monkeypatch):
"""Test config dir defaults to ~/.config/evoscientist."""
monkeypatch.delenv("EVOSCIENTIST_CONFIG_DIR", raising=False)
monkeypatch.delenv("EVOSCIENTIST_HOME", raising=False)
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
config_dir = get_config_dir()
assert config_dir == Path.home() / ".config" / "evoscientist"
@@ -264,23 +242,6 @@ class TestLoadSaveReset:
assert data["provider"] == "openai"
assert data["model"] == "gpt-4o"
def test_save_restricts_config_permissions(self, temp_config_dir, clean_env):
"""Config file permissions should not depend on the process umask."""
original_umask = os.umask(0)
try:
save_config(EvoScientistConfig(anthropic_api_key="test-key"))
finally:
os.umask(original_umask)
config_path = get_config_path()
if os.name == "nt":
assert config_path.exists()
# Windows reports pseudo-permission bits, so we don't test them here.
return
assert config_path.parent.stat().st_mode & 0o777 == 0o700
assert config_path.stat().st_mode & 0o777 == 0o600
def test_load_reads_saved_config(self, temp_config_dir, clean_env):
"""Test that load reads previously saved config."""
original = EvoScientistConfig(
+7 -6
View File
@@ -15,6 +15,7 @@ from EvoScientist.middleware.configurable_model import (
ConfigurableModelMiddleware,
_read_model_override,
)
from tests.conftest import run_async as _run
@contextmanager
@@ -133,7 +134,7 @@ class TestPassThrough:
handler.assert_called_once_with(req)
req.override.assert_not_called()
async def test_async_no_override_passes_request_unchanged(self):
def test_async_no_override_passes_request_unchanged(self):
mw = ConfigurableModelMiddleware()
req = _make_request()
@@ -142,7 +143,7 @@ class TestPassThrough:
return "ok"
with _patched_config({}):
result = await mw.awrap_model_call(req, handler)
result = _run(mw.awrap_model_call(req, handler))
assert result == "ok"
req.override.assert_not_called()
@@ -184,7 +185,7 @@ class TestModelOverride:
assert called_with is not req
assert called_with.model is new_model
async def test_async_override_path_parity(self):
def test_async_override_path_parity(self):
mw = ConfigurableModelMiddleware()
req = _make_request()
new_model = MagicMock()
@@ -201,7 +202,7 @@ class TestModelOverride:
"EvoScientist.llm.get_chat_model", return_value=new_model
) as mock_get,
):
result = await mw.awrap_model_call(req, handler)
result = _run(mw.awrap_model_call(req, handler))
assert result == "ok"
mock_get.assert_called_once_with(model="claude-opus-4-8", provider="anthropic")
@@ -315,7 +316,7 @@ class TestResolveFailure:
handler.assert_called_once_with(req)
req.override.assert_not_called()
async def test_async_falls_back_when_resolve_raises(self):
def test_async_falls_back_when_resolve_raises(self):
mw = ConfigurableModelMiddleware()
req = _make_request()
@@ -332,7 +333,7 @@ class TestResolveFailure:
side_effect=ValueError("unknown model"),
),
):
result = await mw.awrap_model_call(req, handler)
result = _run(mw.awrap_model_call(req, handler))
assert result == "ok"
assert called == [req]
@@ -66,6 +66,7 @@ def test_wrap_model_call_raises_context_overflow():
assert handler.call_count == 1
@pytest.mark.anyio
async def test_awrap_model_call_raises_context_overflow():
# Setup mocks
msgs = [HumanMessage(content=f"msg {i}") for i in range(10)]
@@ -90,6 +91,7 @@ async def test_awrap_model_call_raises_context_overflow():
assert handler.call_count == 1
@pytest.mark.anyio
async def test_awrap_model_call_passes_through_other_errors():
request = ModelRequest(
messages=[],
+6 -4
View File
@@ -2,9 +2,11 @@
from unittest.mock import MagicMock
from tests.conftest import run_async as _run
class TestCurrentCommand:
async def test_prints_thread_workspace_and_memory(self):
def test_prints_thread_workspace_and_memory(self):
from EvoScientist.commands.base import CommandContext
from EvoScientist.commands.implementation.general import CurrentCommand
@@ -15,14 +17,14 @@ class TestCurrentCommand:
ui=ui,
workspace_dir="/tmp/ws",
)
await CurrentCommand().execute(ctx, [])
_run(CurrentCommand().execute(ctx, []))
# Three append_system calls: Thread, Workspace, Memory dir.
calls = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Thread: abc123" in s for s in calls)
assert any("Workspace:" in s for s in calls)
assert any("Memory dir:" in s for s in calls)
async def test_skips_workspace_when_none(self):
def test_skips_workspace_when_none(self):
from EvoScientist.commands.base import CommandContext
from EvoScientist.commands.implementation.general import CurrentCommand
@@ -33,7 +35,7 @@ class TestCurrentCommand:
ui=ui,
workspace_dir=None,
)
await CurrentCommand().execute(ctx, [])
_run(CurrentCommand().execute(ctx, []))
calls = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Thread: abc123" in s for s in calls)
assert not any("Workspace:" in s for s in calls)
+15 -14
View File
@@ -2,6 +2,7 @@
from unittest.mock import AsyncMock, MagicMock
from tests.conftest import run_async as _run
from tests.fakes import FakeGraphGateway, FakeThreadStore
@@ -20,17 +21,17 @@ def _ctx(thread_id="current", thread_store=None):
class TestDeleteCommand:
async def test_refuses_to_delete_current(self):
def test_refuses_to_delete_current(self):
from EvoScientist.commands.implementation.session import DeleteCommand
thread_store = FakeThreadStore(resolved_thread_id="current", deleted=True)
ctx, ui = _ctx(thread_id="current", thread_store=thread_store)
await DeleteCommand().execute(ctx, ["current"])
_run(DeleteCommand().execute(ctx, ["current"]))
assert ("delete_thread", "current") not in thread_store.calls
msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Cannot delete the current session" in m for m in msgs)
async def test_happy_path_success(self):
def test_happy_path_success(self):
from EvoScientist.commands.implementation.session import DeleteCommand
ctx, ui = _ctx(
@@ -40,45 +41,45 @@ class TestDeleteCommand:
deleted=True,
),
)
await DeleteCommand().execute(ctx, ["other-thread"])
_run(DeleteCommand().execute(ctx, ["other-thread"]))
msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Deleted session other-thread" in m for m in msgs)
async def test_not_found(self):
def test_not_found(self):
from EvoScientist.commands.implementation.session import DeleteCommand
ctx, ui = _ctx()
await DeleteCommand().execute(ctx, ["missing"])
_run(DeleteCommand().execute(ctx, ["missing"]))
msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("not found" in m for m in msgs)
async def test_ambiguous_prefix(self):
def test_ambiguous_prefix(self):
from EvoScientist.commands.implementation.session import DeleteCommand
ctx, ui = _ctx(thread_store=FakeThreadStore(matches=["abc-one", "abc-two"]))
await DeleteCommand().execute(ctx, ["abc"])
_run(DeleteCommand().execute(ctx, ["abc"]))
msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Ambiguous" in m for m in msgs)
async def test_prefix_resolves_to_unique_match(self):
def test_prefix_resolves_to_unique_match(self):
from EvoScientist.commands.implementation.session import DeleteCommand
ctx, ui = _ctx(
thread_store=FakeThreadStore(resolved_thread_id="abc-one", deleted=True)
)
await DeleteCommand().execute(ctx, ["abc"])
_run(DeleteCommand().execute(ctx, ["abc"]))
msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Deleted session abc-one" in m for m in msgs)
async def test_no_arg_empty_sessions_prints_notice(self):
def test_no_arg_empty_sessions_prints_notice(self):
from EvoScientist.commands.implementation.session import DeleteCommand
ctx, ui = _ctx()
await DeleteCommand().execute(ctx, [])
_run(DeleteCommand().execute(ctx, []))
msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("No sessions to delete" in m for m in msgs)
async def test_no_arg_calls_picker_returns_none(self):
def test_no_arg_calls_picker_returns_none(self):
"""When no arg and picker returns None, nothing is deleted."""
from EvoScientist.commands.implementation.session import DeleteCommand
@@ -95,5 +96,5 @@ class TestDeleteCommand:
]
store = FakeThreadStore(threads=threads)
ctx.graph_gateway = FakeGraphGateway(thread_store=store)
await DeleteCommand().execute(ctx, [])
_run(DeleteCommand().execute(ctx, []))
ui.wait_for_thread_pick.assert_awaited_once()
+33 -32
View File
@@ -7,6 +7,7 @@ import pytest
from EvoScientist.channels.base import ChannelError, OutboundMessage
from EvoScientist.channels.dingtalk.channel import DingTalkChannel, DingTalkConfig
from tests.conftest import run_async as _run
class TestDingTalkConfig:
@@ -40,30 +41,30 @@ class TestDingTalkChannel:
assert channel._running is False
assert channel.name == "dingtalk"
async def test_start_raises_without_credentials(self):
def test_start_raises_without_credentials(self):
config = DingTalkConfig(client_id="", client_secret="")
channel = DingTalkChannel(config)
with pytest.raises(ChannelError, match="client_id and client_secret"):
await channel.start()
_run(channel.start())
async def test_start_raises_without_client_id(self):
def test_start_raises_without_client_id(self):
config = DingTalkConfig(client_id="", client_secret="secret")
channel = DingTalkChannel(config)
with pytest.raises(ChannelError, match="client_id and client_secret"):
await channel.start()
_run(channel.start())
async def test_start_raises_without_client_secret(self):
def test_start_raises_without_client_secret(self):
config = DingTalkConfig(client_id="id", client_secret="")
channel = DingTalkChannel(config)
with pytest.raises(ChannelError, match="client_id and client_secret"):
await channel.start()
_run(channel.start())
async def test_stop_when_not_running(self):
def test_stop_when_not_running(self):
config = DingTalkConfig(client_id="test-id", client_secret="test-secret")
channel = DingTalkChannel(config)
await channel.stop()
_run(channel.stop())
async def test_send_returns_false_without_client(self):
def test_send_returns_false_without_client(self):
config = DingTalkConfig(client_id="test-id", client_secret="test-secret")
channel = DingTalkChannel(config)
msg = OutboundMessage(
@@ -72,7 +73,7 @@ class TestDingTalkChannel:
content="hello",
metadata={"chat_id": "user123"},
)
result = await channel.send(msg)
result = _run(channel.send(msg))
assert result is False
def test_capabilities(self):
@@ -129,20 +130,20 @@ class TestDingTalkWsMessageParsing:
channel._token_expires = 9999999999
return channel
async def test_system_ping_ack(self):
def test_system_ping_ack(self):
channel = self._make_channel()
data = {
"type": "SYSTEM",
"headers": {"topic": "ping", "messageId": "ping-1"},
"data": "pong-data",
}
await channel._on_ws_message(data)
_run(channel._on_ws_message(data))
channel._ws_session.send_str.assert_called_once()
sent = json.loads(channel._ws_session.send_str.call_args[0][0])
assert sent["code"] == 200
assert sent["data"] == "pong-data"
async def test_callback_text_message(self):
def test_callback_text_message(self):
channel = self._make_channel()
channel._enqueue_raw = AsyncMock()
@@ -157,14 +158,14 @@ class TestDingTalkWsMessageParsing:
"headers": {"messageId": "msg-1", "contentType": "application/json"},
"data": json.dumps(payload),
}
await channel._on_ws_message(data)
_run(channel._on_ws_message(data))
channel._enqueue_raw.assert_called_once()
raw = channel._enqueue_raw.call_args[0][0]
assert raw.text == "hello bot"
assert raw.sender_id == "staff123"
assert raw.is_group is False
async def test_callback_group_message_mention(self):
def test_callback_group_message_mention(self):
channel = self._make_channel()
channel._enqueue_raw = AsyncMock()
@@ -180,12 +181,12 @@ class TestDingTalkWsMessageParsing:
"headers": {"messageId": "msg-2"},
"data": json.dumps(payload),
}
await channel._on_ws_message(data)
_run(channel._on_ws_message(data))
raw = channel._enqueue_raw.call_args[0][0]
assert raw.is_group is True
assert raw.was_mentioned is True
async def test_callback_group_no_mention(self):
def test_callback_group_no_mention(self):
channel = self._make_channel()
channel._enqueue_raw = AsyncMock()
@@ -200,12 +201,12 @@ class TestDingTalkWsMessageParsing:
"headers": {"messageId": "msg-3"},
"data": json.dumps(payload),
}
await channel._on_ws_message(data)
_run(channel._on_ws_message(data))
raw = channel._enqueue_raw.call_args[0][0]
assert raw.is_group is True
assert raw.was_mentioned is False
async def test_ignores_non_callback(self):
def test_ignores_non_callback(self):
channel = self._make_channel()
channel._enqueue_raw = AsyncMock()
@@ -214,10 +215,10 @@ class TestDingTalkWsMessageParsing:
"headers": {"messageId": "msg-x"},
"data": "{}",
}
await channel._on_ws_message(data)
_run(channel._on_ws_message(data))
channel._enqueue_raw.assert_not_called()
async def test_ignores_empty_content(self):
def test_ignores_empty_content(self):
channel = self._make_channel()
channel._enqueue_raw = AsyncMock()
@@ -231,20 +232,20 @@ class TestDingTalkWsMessageParsing:
"headers": {"messageId": "msg-e"},
"data": json.dumps(payload),
}
await channel._on_ws_message(data)
_run(channel._on_ws_message(data))
channel._enqueue_raw.assert_not_called()
async def test_non_dict_data_ignored(self):
def test_non_dict_data_ignored(self):
channel = self._make_channel()
channel._enqueue_raw = AsyncMock()
await channel._on_ws_message("not a dict")
_run(channel._on_ws_message("not a dict"))
channel._enqueue_raw.assert_not_called()
class TestDingTalkSendChunk:
"""Test _send_chunk with mocked HTTP client."""
async def test_send_chunk_calls_api(self):
def test_send_chunk_calls_api(self):
config = DingTalkConfig(client_id="test-app", client_secret="test-secret")
channel = DingTalkChannel(config)
channel._access_token = "fake-token"
@@ -255,7 +256,7 @@ class TestDingTalkSendChunk:
channel._http_client = MagicMock()
channel._http_client.post = AsyncMock(return_value=mock_response)
await channel._send_chunk("user1", "formatted", "raw text", None, {})
_run(channel._send_chunk("user1", "formatted", "raw text", None, {}))
channel._http_client.post.assert_called_once()
call_args = channel._http_client.post.call_args
body = call_args.kwargs.get("json") or call_args[1].get("json")
@@ -272,21 +273,21 @@ class TestDingTalkChannelRegistration:
class TestDingTalkProbe:
async def test_missing_credentials(self):
def test_missing_credentials(self):
from EvoScientist.channels.dingtalk.probe import validate_dingtalk
ok, msg = await validate_dingtalk("", "")
ok, msg = _run(validate_dingtalk("", ""))
assert ok is False
assert "required" in msg
async def test_missing_client_id(self):
def test_missing_client_id(self):
from EvoScientist.channels.dingtalk.probe import validate_dingtalk
ok, _msg = await validate_dingtalk("", "secret")
ok, _msg = _run(validate_dingtalk("", "secret"))
assert ok is False
async def test_missing_client_secret(self):
def test_missing_client_secret(self):
from EvoScientist.channels.dingtalk.probe import validate_dingtalk
ok, _msg = await validate_dingtalk("id", "")
ok, _msg = _run(validate_dingtalk("id", ""))
assert ok is False
+7 -6
View File
@@ -4,6 +4,7 @@ import pytest
from EvoScientist.channels.base import ChannelError
from EvoScientist.channels.discord.channel import DiscordChannel, DiscordConfig
from tests.conftest import run_async as _run
class TestDiscordChannel:
@@ -13,18 +14,18 @@ class TestDiscordChannel:
assert channel.config is config
assert channel._running is False
async def test_start_raises_without_token_or_library(self):
def test_start_raises_without_token_or_library(self):
config = DiscordConfig(bot_token="")
channel = DiscordChannel(config)
with pytest.raises(ChannelError):
await channel.start()
_run(channel.start())
async def test_stop_when_not_running(self):
def test_stop_when_not_running(self):
config = DiscordConfig(bot_token="test")
channel = DiscordChannel(config)
await channel.stop()
_run(channel.stop())
async def test_send_returns_false_without_client(self):
def test_send_returns_false_without_client(self):
from EvoScientist.channels.base import OutboundMessage
config = DiscordConfig(bot_token="test")
@@ -35,5 +36,5 @@ class TestDiscordChannel:
content="hello",
metadata={"chat_id": "123"},
)
result = await channel.send(msg)
result = _run(channel.send(msg))
assert result is False
+10 -8
View File
@@ -2,6 +2,8 @@
from unittest.mock import AsyncMock, MagicMock, patch
from tests.conftest import run_async as _run
def _ctx(supports_interactive=True):
from EvoScientist.commands.base import CommandContext
@@ -29,7 +31,7 @@ _INDEX = [
class TestInstallSkills:
async def test_picker_cancel_no_install(self):
def test_picker_cancel_no_install(self):
from EvoScientist.commands.implementation.skills import InstallSkills
ctx, ui = _ctx()
@@ -43,12 +45,12 @@ class TestInstallSkills:
"EvoScientist.tools.skills_manager.install_skill",
) as install_mock,
):
await InstallSkills().execute(ctx, [])
_run(InstallSkills().execute(ctx, []))
install_mock.assert_not_called()
msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Browse cancelled" in m for m in msgs)
async def test_picker_returns_selections_installs_each(self):
def test_picker_returns_selections_installs_each(self):
from EvoScientist.commands.implementation.skills import InstallSkills
ctx, ui = _ctx()
@@ -66,10 +68,10 @@ class TestInstallSkills:
return_value={"success": True, "name": "x"},
) as install_mock,
):
await InstallSkills().execute(ctx, [])
_run(InstallSkills().execute(ctx, []))
assert install_mock.call_count == 2
async def test_channel_auto_install_on_tag(self):
def test_channel_auto_install_on_tag(self):
"""Non-interactive UI + tag arg → auto-installs matching skills."""
from EvoScientist.commands.implementation.skills import InstallSkills
@@ -84,12 +86,12 @@ class TestInstallSkills:
return_value={"success": True, "name": "x"},
) as install_mock,
):
await InstallSkills().execute(ctx, ["core"])
_run(InstallSkills().execute(ctx, ["core"]))
# "core" matches research-ideation only → 1 install, no picker call
assert install_mock.call_count == 1
ui.wait_for_skill_browse.assert_not_called()
async def test_fetch_failure_prints_error(self):
def test_fetch_failure_prints_error(self):
from EvoScientist.commands.implementation.skills import InstallSkills
ctx, ui = _ctx()
@@ -97,6 +99,6 @@ class TestInstallSkills:
"EvoScientist.tools.skills_manager.fetch_remote_skill_index",
side_effect=RuntimeError("network fail"),
):
await InstallSkills().execute(ctx, [])
_run(InstallSkills().execute(ctx, []))
msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Failed to fetch" in m for m in msgs)
+4 -2
View File
@@ -2,9 +2,11 @@
from unittest.mock import MagicMock
from tests.conftest import run_async as _run
class TestExitCommand:
async def test_execute_calls_force_quit(self):
def test_execute_calls_force_quit(self):
from EvoScientist.commands.base import CommandContext
from EvoScientist.commands.implementation.session import ExitCommand
@@ -15,7 +17,7 @@ class TestExitCommand:
ui=ui,
)
cmd = ExitCommand()
await cmd.execute(ctx, [])
_run(cmd.execute(ctx, []))
ui.force_quit.assert_called_once()
def test_aliases_registered(self):
+39 -38
View File
@@ -14,6 +14,7 @@ from EvoScientist.channels.feishu.channel import (
_parse_inline_elements,
_parse_inline_text,
)
from tests.conftest import run_async as _run
class TestFeishuConfig:
@@ -56,24 +57,24 @@ class TestFeishuChannel:
assert channel._running is False
assert channel.name == "feishu"
async def test_start_raises_without_app_id(self):
def test_start_raises_without_app_id(self):
config = FeishuConfig(app_id="", app_secret="test-secret")
channel = FeishuChannel(config)
with pytest.raises(ChannelError, match="app_id"):
await channel.start()
_run(channel.start())
async def test_start_raises_without_app_secret(self):
def test_start_raises_without_app_secret(self):
config = FeishuConfig(app_id="test-id", app_secret="")
channel = FeishuChannel(config)
with pytest.raises(ChannelError, match="app_secret"):
await channel.start()
_run(channel.start())
async def test_stop_when_not_running(self):
def test_stop_when_not_running(self):
config = FeishuConfig(app_id="test-id", app_secret="test-secret")
channel = FeishuChannel(config)
await channel.stop()
_run(channel.stop())
async def test_send_returns_false_without_client(self):
def test_send_returns_false_without_client(self):
config = FeishuConfig(app_id="test-id", app_secret="test-secret")
channel = FeishuChannel(config)
msg = OutboundMessage(
@@ -82,7 +83,7 @@ class TestFeishuChannel:
content="hello",
metadata={"chat_id": "oc_test"},
)
result = await channel.send(msg)
result = _run(channel.send(msg))
assert result is False
def test_capabilities(self):
@@ -200,7 +201,7 @@ class TestFeishuWebhookEvent:
channel._enqueue_raw = AsyncMock()
return channel
async def test_text_message_v2(self):
def test_text_message_v2(self):
channel = self._make_channel()
event = {
"sender": {
@@ -216,7 +217,7 @@ class TestFeishuWebhookEvent:
"create_time": "1700000000000",
},
}
await channel._on_message(event)
_run(channel._on_message(event))
channel._enqueue_raw.assert_called_once()
raw = channel._enqueue_raw.call_args[0][0]
assert raw.text == "hello feishu"
@@ -224,7 +225,7 @@ class TestFeishuWebhookEvent:
assert raw.chat_id == "oc_chat1"
assert raw.is_group is False
async def test_group_message_with_mention(self):
def test_group_message_with_mention(self):
channel = self._make_channel()
event = {
"sender": {
@@ -241,13 +242,13 @@ class TestFeishuWebhookEvent:
"mentions": [{"key": "@_user_1", "id": {}}],
},
}
await channel._on_message(event)
_run(channel._on_message(event))
raw = channel._enqueue_raw.call_args[0][0]
assert raw.is_group is True
assert raw.was_mentioned is True
assert channel._mention_names == ["@_user_1"]
async def test_group_message_no_mention(self):
def test_group_message_no_mention(self):
channel = self._make_channel()
event = {
"sender": {
@@ -263,12 +264,12 @@ class TestFeishuWebhookEvent:
"create_time": "1700000000000",
},
}
await channel._on_message(event)
_run(channel._on_message(event))
raw = channel._enqueue_raw.call_args[0][0]
assert raw.is_group is True
assert raw.was_mentioned is False
async def test_skips_bot_messages(self):
def test_skips_bot_messages(self):
channel = self._make_channel()
event = {
"sender": {
@@ -282,10 +283,10 @@ class TestFeishuWebhookEvent:
"content": json.dumps({"text": "bot reply"}),
},
}
await channel._on_message(event)
_run(channel._on_message(event))
channel._enqueue_raw.assert_not_called()
async def test_post_message(self):
def test_post_message(self):
channel = self._make_channel()
post_content = {
"zh_cn": {
@@ -307,12 +308,12 @@ class TestFeishuWebhookEvent:
"create_time": "1700000000000",
},
}
await channel._on_message(event)
_run(channel._on_message(event))
raw = channel._enqueue_raw.call_args[0][0]
assert "Test" in raw.text
assert "Post body" in raw.text
async def test_unsupported_msg_type_annotation(self):
def test_unsupported_msg_type_annotation(self):
channel = self._make_channel()
event = {
"sender": {
@@ -328,7 +329,7 @@ class TestFeishuWebhookEvent:
"create_time": "1700000000000",
},
}
await channel._on_message(event)
_run(channel._on_message(event))
raw = channel._enqueue_raw.call_args[0][0]
assert "share_chat" in raw.text
@@ -336,7 +337,7 @@ class TestFeishuWebhookEvent:
class TestFeishuSendChunk:
"""Test _send_chunk with mocked HTTP client."""
async def test_send_chunk_post_format(self):
def test_send_chunk_post_format(self):
config = FeishuConfig(app_id="test-app", app_secret="test-secret")
channel = FeishuChannel(config)
channel._access_token = "fake-token"
@@ -347,14 +348,14 @@ class TestFeishuSendChunk:
channel._http_client = MagicMock()
channel._http_client.post = AsyncMock(return_value=mock_response)
await channel._send_chunk("oc_chat1", "formatted", "raw **text**", None, {})
_run(channel._send_chunk("oc_chat1", "formatted", "raw **text**", None, {}))
channel._http_client.post.assert_called()
# Should try post format first
call_args = channel._http_client.post.call_args
body = call_args.kwargs.get("json") or call_args[1].get("json")
assert body["receive_id"] == "oc_chat1"
async def test_send_chunk_with_reply(self):
def test_send_chunk_with_reply(self):
config = FeishuConfig(app_id="test-app", app_secret="test-secret")
channel = FeishuChannel(config)
channel._access_token = "fake-token"
@@ -365,7 +366,7 @@ class TestFeishuSendChunk:
channel._http_client = MagicMock()
channel._http_client.post = AsyncMock(return_value=mock_response)
await channel._send_chunk("oc_chat1", "reply", "reply text", "om_reply_id", {})
_run(channel._send_chunk("oc_chat1", "reply", "reply text", "om_reply_id", {}))
# Should call the reply API endpoint
first_call_url = channel._http_client.post.call_args_list[0][0][0]
assert "reply" in first_call_url
@@ -483,17 +484,17 @@ class TestFeishuChannelRegistration:
class TestFeishuProbe:
async def test_missing_app_id(self):
def test_missing_app_id(self):
from EvoScientist.channels.feishu.probe import validate_feishu_credentials
ok, msg = await validate_feishu_credentials("", "secret")
ok, msg = _run(validate_feishu_credentials("", "secret"))
assert ok is False
assert "app_id" in msg
async def test_missing_app_secret(self):
def test_missing_app_secret(self):
from EvoScientist.channels.feishu.probe import validate_feishu_credentials
ok, msg = await validate_feishu_credentials("id", "")
ok, msg = _run(validate_feishu_credentials("id", ""))
assert ok is False
assert "app_secret" in msg
@@ -509,7 +510,7 @@ class TestFeishuWebSocketMode:
)
assert config.subscription_mode == "websocket"
async def test_start_websocket_raises_without_lark_oapi(self):
def test_start_websocket_raises_without_lark_oapi(self):
config = FeishuConfig(
app_id="test-id",
app_secret="test-secret",
@@ -519,9 +520,9 @@ class TestFeishuWebSocketMode:
# Temporarily hide lark_oapi if it's installed
with patch.dict(sys.modules, {"lark_oapi": None}):
with pytest.raises(ChannelError, match="lark-oapi"):
await channel.start()
_run(channel.start())
async def test_start_webhook_mode_still_works(self):
def test_start_webhook_mode_still_works(self):
"""Ensure subscription_mode='webhook' still validates as before."""
config = FeishuConfig(
app_id="",
@@ -530,9 +531,9 @@ class TestFeishuWebSocketMode:
)
channel = FeishuChannel(config)
with pytest.raises(ChannelError, match="app_id"):
await channel.start()
_run(channel.start())
async def test_invalid_subscription_mode_raises(self):
def test_invalid_subscription_mode_raises(self):
config = FeishuConfig(
app_id="test-id",
app_secret="test-secret",
@@ -540,9 +541,9 @@ class TestFeishuWebSocketMode:
)
channel = FeishuChannel(config)
with pytest.raises(ChannelError, match="Invalid feishu_subscription_mode"):
await channel.start()
_run(channel.start())
async def test_on_lark_sdk_message_bridges_to_on_message(self):
def test_on_lark_sdk_message_bridges_to_on_message(self):
"""Test that _on_lark_sdk_message enqueues event dict via queue."""
import queue as queue_mod
@@ -593,14 +594,14 @@ class TestFeishuWebSocketMode:
)
# Verify the consumer processes it correctly
await channel._on_message(event_dict)
_run(channel._on_message(event_dict))
channel._enqueue_raw.assert_called_once()
raw = channel._enqueue_raw.call_args[0][0]
assert raw.text == "hello from websocket"
assert raw.sender_id == "ou_test_ws"
assert raw.is_group is False
async def test_cleanup_websocket_mode(self):
def test_cleanup_websocket_mode(self):
config = FeishuConfig(
app_id="test-id",
app_secret="test-secret",
@@ -616,7 +617,7 @@ class TestFeishuWebSocketMode:
channel._ws_consumer_task = None
channel._access_token = "fake-token"
await channel._cleanup()
_run(channel._cleanup())
mock_client.aclose.assert_called_once()
assert channel._http_client is None
+41 -31
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from unittest.mock import MagicMock
@@ -178,7 +179,7 @@ def test_launch_background_run_deletes_thread_when_run_creation_fails(monkeypatc
fake_client.threads.delete.assert_called_once_with("thread-1")
async def test_async_launch_background_run_deletes_thread_when_run_creation_fails(
def test_async_launch_background_run_deletes_thread_when_run_creation_fails(
monkeypatch,
):
monkeypatch.setattr(
@@ -203,8 +204,11 @@ async def test_async_launch_background_run_deletes_thread_when_run_creation_fail
lambda **_kwargs: SimpleNamespace(threads=_Threads(), runs=_Runs()),
)
with pytest.raises(RuntimeError, match="run creation failed"):
await background_runs.alaunch_background_run(_request())
async def run() -> None:
with pytest.raises(RuntimeError, match="run creation failed"):
await background_runs.alaunch_background_run(_request())
asyncio.run(run())
assert deleted == ["thread-1"]
@@ -274,7 +278,7 @@ def test_sync_status_watcher_preserves_thread_on_poll_failure(
assert deleted == []
async def test_async_status_watcher_aborts_and_deletes_thread_on_error_status():
def test_async_status_watcher_aborts_and_deletes_thread_on_error_status():
finished: list[background_runs.BackgroundRun] = []
aborted: list[background_runs.BackgroundRun] = []
deleted: list[str] = []
@@ -287,26 +291,29 @@ async def test_async_status_watcher_aborts_and_deletes_thread_on_error_status():
async def delete(self, thread_id: str):
deleted.append(thread_id)
await background_runs.awatch_background_run(
SimpleNamespace(runs=_Runs(), threads=_Threads()),
thread_id="thread-1",
run_id="run-1",
name="test worker",
hooks=background_runs.BackgroundRunHooks(
on_finished=finished.append,
on_aborted=aborted.append,
),
watcher_config=background_runs.BackgroundRunWatcherConfig(
poll_interval_seconds=0,
),
)
async def run() -> None:
await background_runs.awatch_background_run(
SimpleNamespace(runs=_Runs(), threads=_Threads()),
thread_id="thread-1",
run_id="run-1",
name="test worker",
hooks=background_runs.BackgroundRunHooks(
on_finished=finished.append,
on_aborted=aborted.append,
),
watcher_config=background_runs.BackgroundRunWatcherConfig(
poll_interval_seconds=0,
),
)
asyncio.run(run())
assert finished == []
assert [run.run_id for run in aborted] == ["run-1"]
assert deleted == ["thread-1"]
async def test_async_status_watcher_preserves_run_url():
def test_async_status_watcher_preserves_run_url():
finished: list[background_runs.BackgroundRun] = []
class _Runs:
@@ -317,18 +324,21 @@ async def test_async_status_watcher_preserves_run_url():
async def delete(self, _thread_id: str):
return None
await background_runs.awatch_background_run(
SimpleNamespace(runs=_Runs(), threads=_Threads()),
url="http://worker.example",
thread_id="thread-1",
run_id="run-1",
name="test worker",
hooks=background_runs.BackgroundRunHooks(
on_finished=finished.append,
),
watcher_config=background_runs.BackgroundRunWatcherConfig(
poll_interval_seconds=0,
),
)
async def run() -> None:
await background_runs.awatch_background_run(
SimpleNamespace(runs=_Runs(), threads=_Threads()),
url="http://worker.example",
thread_id="thread-1",
run_id="run-1",
name="test worker",
hooks=background_runs.BackgroundRunHooks(
on_finished=finished.append,
),
watcher_config=background_runs.BackgroundRunWatcherConfig(
poll_interval_seconds=0,
),
)
asyncio.run(run())
assert [run.url for run in finished] == ["http://worker.example"]
+75 -66
View File
@@ -20,6 +20,7 @@ from EvoScientist.gateway import (
)
from EvoScientist.gateway.server import _THREAD_SEARCH_LIMIT
from EvoScientist.stream import display as display_mod
from tests.conftest import run_async
from tests.fakes import (
FakeGraphGateway,
FakeLangGraphClient,
@@ -29,7 +30,7 @@ from tests.fakes import (
)
async def test_local_gateway_streams_from_injected_streamer():
def test_local_gateway_streams_from_injected_streamer():
seen: dict[str, Any] = {}
async def _streamer(agent, message, thread_id, **kwargs):
@@ -59,7 +60,7 @@ async def test_local_gateway_streams_from_injected_streamer():
return [event async for event in gateway.stream_events(request)]
with patch("EvoScientist.stream.events.stream_agent_events", new=_streamer):
events = await _collect()
events = run_async(_collect())
assert events == [
{"type": "text", "content": "hi"},
@@ -74,7 +75,7 @@ async def test_local_gateway_streams_from_injected_streamer():
}
async def test_local_graph_gateway_delegates_thread_operations():
def test_local_graph_gateway_delegates_thread_operations():
thread_store = FakeThreadStore(
generated_thread_id="new12345",
threads=[{"thread_id": "abc12345"}],
@@ -101,7 +102,7 @@ async def test_local_graph_gateway_delegates_thread_operations():
"deleted": await gateway.delete_thread("abc12345"),
}
result = await _run()
result = run_async(_run())
assert result["created"] == "new12345"
assert result["threads"] == [{"thread_id": "abc12345"}]
@@ -131,14 +132,16 @@ async def test_local_graph_gateway_delegates_thread_operations():
]
async def test_local_graph_gateway_reads_state_values():
def test_local_graph_gateway_reads_state_values():
agent = MagicMock()
agent.aget_state = AsyncMock(
return_value=SimpleNamespace(values={"async_tasks": {"task-1": {}}})
)
gateway = LocalGraphGateway()
values = await gateway.get_state_values(GraphTarget(local_graph=agent), "abc12345")
values = run_async(
gateway.get_state_values(GraphTarget(local_graph=agent), "abc12345")
)
assert values == {"async_tasks": {"task-1": {}}}
agent.aget_state.assert_awaited_once_with(
@@ -146,15 +149,17 @@ async def test_local_graph_gateway_reads_state_values():
)
async def test_local_graph_gateway_updates_state_values():
def test_local_graph_gateway_updates_state_values():
agent = MagicMock()
agent.aupdate_state = AsyncMock()
gateway = LocalGraphGateway()
await gateway.update_state_values(
GraphTarget(local_graph=agent),
"abc12345",
{"_summarization_event": {"cutoff_index": 2}},
run_async(
gateway.update_state_values(
GraphTarget(local_graph=agent),
"abc12345",
{"_summarization_event": {"cutoff_index": 2}},
)
)
agent.aupdate_state.assert_awaited_once_with(
@@ -164,7 +169,7 @@ async def test_local_graph_gateway_updates_state_values():
)
async def test_local_stream_events_delegates_aclose_to_inner():
def test_local_stream_events_delegates_aclose_to_inner():
cleanup_ran = False
async def _streamer(_agent, _message, _thread_id, **_kwargs):
@@ -189,7 +194,7 @@ async def test_local_stream_events_delegates_aclose_to_inner():
assert cleanup_ran is True
with patch("EvoScientist.stream.events.stream_agent_events", new=_streamer):
await _run()
run_async(_run())
def test_run_streaming_can_consume_injected_gateway():
@@ -223,7 +228,7 @@ def test_run_streaming_can_consume_injected_gateway():
]
async def test_resume_command_consumes_context_gateway():
def test_resume_command_consumes_context_gateway():
from EvoScientist.commands.base import CommandContext
from EvoScientist.commands.implementation.session import ResumeCommand
@@ -241,7 +246,7 @@ async def test_resume_command_consumes_context_gateway():
graph_gateway=FakeGraphGateway(thread_store=thread_store),
)
await ResumeCommand().execute(ctx, ["abc"])
run_async(ResumeCommand().execute(ctx, ["abc"]))
assert ctx.thread_id == "abc12345"
assert ctx.workspace_dir == "/restored"
@@ -282,7 +287,7 @@ def test_cmd_run_passes_local_graph_gateway(monkeypatch):
assert seen["gateway"].thread_store is thread_store
async def test_langgraph_server_thread_store_delegates_to_sdk_threads():
def test_langgraph_server_thread_store_delegates_to_sdk_threads():
threads = FakeLangGraphThreadsClient(
threads=[
{
@@ -330,7 +335,7 @@ async def test_langgraph_server_thread_store_delegates_to_sdk_threads():
"deleted": await store.delete_thread("abc12345"),
}
result = await _run()
result = run_async(_run())
assert result["created"] == "server-thread"
assert len(threads.created) == 1
@@ -374,7 +379,7 @@ async def test_langgraph_server_thread_store_delegates_to_sdk_threads():
assert threads.deleted == ["abc12345"]
async def test_langgraph_server_thread_store_limit_zero_pages_all_threads():
def test_langgraph_server_thread_store_limit_zero_pages_all_threads():
rows = [
{
"thread_id": f"thread-{index}",
@@ -387,7 +392,7 @@ async def test_langgraph_server_thread_store_limit_zero_pages_all_threads():
client=FakeLangGraphClient(threads),
)
result = await store.list_threads(limit=0)
result = run_async(store.list_threads(limit=0))
assert [row["thread_id"] for row in result] == [
f"thread-{index}" for index in range(_THREAD_SEARCH_LIMIT + 1)
@@ -398,7 +403,7 @@ async def test_langgraph_server_thread_store_limit_zero_pages_all_threads():
]
async def test_langgraph_server_thread_store_positive_limit_uses_single_search():
def test_langgraph_server_thread_store_positive_limit_uses_single_search():
threads = FakeLangGraphThreadsClient(
threads=[
{
@@ -412,7 +417,7 @@ async def test_langgraph_server_thread_store_positive_limit_uses_single_search()
client=FakeLangGraphClient(threads),
)
result = await store.list_threads(limit=2)
result = run_async(store.list_threads(limit=2))
assert [row["thread_id"] for row in result] == ["thread-0", "thread-1"]
assert [(search["limit"], search["offset"]) for search in threads.searches] == [
@@ -420,7 +425,7 @@ async def test_langgraph_server_thread_store_positive_limit_uses_single_search()
]
async def test_langgraph_server_thread_store_prefix_resolution_skips_exact_lookup():
def test_langgraph_server_thread_store_prefix_resolution_skips_exact_lookup():
threads = FakeLangGraphThreadsClient(
threads=[
{
@@ -433,14 +438,14 @@ async def test_langgraph_server_thread_store_prefix_resolution_skips_exact_looku
client=FakeLangGraphClient(threads),
)
result = await store.resolve_thread_id_prefix("abc")
result = run_async(store.resolve_thread_id_prefix("abc"))
assert result == ("abc12345", [])
assert threads.gets == []
assert len(threads.searches) == 1
async def test_langgraph_server_thread_store_prefix_resolution_pages_all_threads():
def test_langgraph_server_thread_store_prefix_resolution_pages_all_threads():
rows = [
{
"thread_id": f"thread-{index}",
@@ -459,7 +464,7 @@ async def test_langgraph_server_thread_store_prefix_resolution_pages_all_threads
client=FakeLangGraphClient(threads),
)
result = await store.resolve_thread_id_prefix("older-thread")
result = run_async(store.resolve_thread_id_prefix("older-thread"))
assert result == ("older-thread-match", [])
assert [(search["limit"], search["offset"]) for search in threads.searches] == [
@@ -468,7 +473,7 @@ async def test_langgraph_server_thread_store_prefix_resolution_pages_all_threads
]
async def test_langgraph_server_thread_store_uuid_resolution_uses_exact_lookup():
def test_langgraph_server_thread_store_uuid_resolution_uses_exact_lookup():
thread_id = "019ed9e4-4253-7f62-b50f-f0470a4b3c9f"
threads = FakeLangGraphThreadsClient(
threads=[
@@ -482,14 +487,14 @@ async def test_langgraph_server_thread_store_uuid_resolution_uses_exact_lookup()
client=FakeLangGraphClient(threads),
)
result = await store.resolve_thread_id_prefix(thread_id)
result = run_async(store.resolve_thread_id_prefix(thread_id))
assert result == (thread_id, [])
assert threads.gets == [thread_id]
assert threads.searches == []
async def test_langgraph_server_thread_store_uuid_resolution_filters_graph_id():
def test_langgraph_server_thread_store_uuid_resolution_filters_graph_id():
thread_id = "019ed9e4-4253-7f62-b50f-f0470a4b3c9f"
threads = FakeLangGraphThreadsClient(
threads=[
@@ -503,7 +508,7 @@ async def test_langgraph_server_thread_store_uuid_resolution_filters_graph_id():
client=FakeLangGraphClient(threads),
)
result = await store.resolve_thread_id_prefix(thread_id)
result = run_async(store.resolve_thread_id_prefix(thread_id))
assert result == (None, [])
assert threads.gets == [thread_id]
@@ -512,7 +517,7 @@ async def test_langgraph_server_thread_store_uuid_resolution_filters_graph_id():
]
async def test_langgraph_server_thread_store_clones_thread_with_metadata():
def test_langgraph_server_thread_store_clones_thread_with_metadata():
clone_metadata = {
"clone_purpose": "memory_extraction",
"source_thread_id": "source-thread",
@@ -529,8 +534,8 @@ async def test_langgraph_server_thread_store_clones_thread_with_metadata():
client=FakeLangGraphClient(threads),
)
cloned_thread_id = await store.clone_thread(
"source-thread", metadata=clone_metadata
cloned_thread_id = run_async(
store.clone_thread("source-thread", metadata=clone_metadata)
)
assert cloned_thread_id == "source-thread-copy"
@@ -547,7 +552,7 @@ async def test_langgraph_server_thread_store_clones_thread_with_metadata():
}
async def test_langgraph_server_thread_store_rejects_copy_without_thread_id():
def test_langgraph_server_thread_store_rejects_copy_without_thread_id():
threads = FakeLangGraphThreadsClient(
threads=[{"thread_id": "source-thread", "metadata": {"graph_id": "agent"}}],
copy_response=None,
@@ -560,10 +565,10 @@ async def test_langgraph_server_thread_store_rejects_copy_without_thread_id():
await store.clone_thread("source-thread")
with pytest.raises(RuntimeError, match="did not return a cloned thread id"):
await _run()
run_async(_run())
async def test_langgraph_server_gateway_clones_thread():
def test_langgraph_server_gateway_clones_thread():
threads = FakeLangGraphThreadsClient(
threads=[{"thread_id": "source-thread", "metadata": {"graph_id": "agent"}}]
)
@@ -573,10 +578,12 @@ async def test_langgraph_server_gateway_clones_thread():
)
)
cloned_thread_id = await gateway.clone_thread(
"source-thread",
metadata={"clone_purpose": "manual"},
target=GraphTarget(graph_id="agent"),
cloned_thread_id = run_async(
gateway.clone_thread(
"source-thread",
metadata={"clone_purpose": "manual"},
target=GraphTarget(graph_id="agent"),
)
)
assert cloned_thread_id == "source-thread-copy"
@@ -585,12 +592,12 @@ async def test_langgraph_server_gateway_clones_thread():
]
async def test_local_graph_gateway_clone_thread_is_explicitly_unsupported():
def test_local_graph_gateway_clone_thread_is_explicitly_unsupported():
async def _run():
await LocalGraphGateway().clone_thread("source-thread")
with pytest.raises(NotImplementedError, match="does not support thread cloning"):
await _run()
run_async(_run())
def test_runtime_gateways_can_use_langgraph_server_backend():
@@ -609,7 +616,7 @@ def test_runtime_gateways_can_use_langgraph_server_backend():
assert gateway.thread_store is runtime_gateways.thread_store
async def test_langgraph_server_gateway_reads_state_values():
def test_langgraph_server_gateway_reads_state_values():
threads = FakeLangGraphThreadsClient(
threads=[{"thread_id": "abc12345", "metadata": {"graph_id": "EvoScientist"}}],
states={"abc12345": {"values": {"async_tasks": {"task-1": {}}}}},
@@ -620,12 +627,12 @@ async def test_langgraph_server_gateway_reads_state_values():
)
)
values = await gateway.get_state_values(GraphTarget(), "abc12345")
values = run_async(gateway.get_state_values(GraphTarget(), "abc12345"))
assert values == {"async_tasks": {"task-1": {}}}
async def test_langgraph_server_gateway_messages_apply_summarization_event():
def test_langgraph_server_gateway_messages_apply_summarization_event():
threads = FakeLangGraphThreadsClient(
threads=[{"thread_id": "abc12345", "metadata": {"graph_id": "EvoScientist"}}],
states={
@@ -651,7 +658,7 @@ async def test_langgraph_server_gateway_messages_apply_summarization_event():
)
)
messages = await gateway.get_thread_messages("abc12345")
messages = run_async(gateway.get_thread_messages("abc12345"))
assert len(messages) == 2
assert isinstance(messages[0], AIMessage)
@@ -660,7 +667,7 @@ async def test_langgraph_server_gateway_messages_apply_summarization_event():
assert messages[1].content == "third"
async def test_langgraph_server_gateway_updates_state_values():
def test_langgraph_server_gateway_updates_state_values():
threads = FakeLangGraphThreadsClient(
threads=[{"thread_id": "abc12345", "metadata": {"graph_id": "EvoScientist"}}],
)
@@ -670,10 +677,12 @@ async def test_langgraph_server_gateway_updates_state_values():
)
)
await gateway.update_state_values(
GraphTarget(),
"abc12345",
{"_summarization_event": {"cutoff_index": 2}},
run_async(
gateway.update_state_values(
GraphTarget(),
"abc12345",
{"_summarization_event": {"cutoff_index": 2}},
)
)
assert threads.state_updates == [
@@ -681,7 +690,7 @@ async def test_langgraph_server_gateway_updates_state_values():
]
async def test_langgraph_server_gateway_streams_root_protocol_events():
def test_langgraph_server_gateway_streams_root_protocol_events():
stream = FakeLangGraphThreadStream(
"abc12345",
events=[
@@ -728,7 +737,7 @@ async def test_langgraph_server_gateway_streams_root_protocol_events():
)
]
events = await _collect()
events = run_async(_collect())
assert len(threads.created) == 1
assert threads.created[0]["thread_id"] == "abc12345"
@@ -795,7 +804,7 @@ def _root_message_finish() -> dict[str, object]:
}
async def _collect_server_gateway_stream(
def _collect_server_gateway_stream(
events: list[dict[str, object]],
*,
state_messages: list[dict[str, object]] | None = None,
@@ -823,11 +832,11 @@ async def _collect_server_gateway_stream(
)
]
return await _collect()
return run_async(_collect())
async def test_langgraph_server_gateway_streams_value_message_snapshots():
events = await _collect_server_gateway_stream(
def test_langgraph_server_gateway_streams_value_message_snapshots():
events = _collect_server_gateway_stream(
[
_value_snapshot([_OLD_AI, _HUMAN]),
_value_snapshot([_OLD_AI, _HUMAN, _NEW_AI]),
@@ -841,8 +850,8 @@ async def test_langgraph_server_gateway_streams_value_message_snapshots():
]
async def test_langgraph_server_gateway_values_do_not_duplicate_message_stream():
events = await _collect_server_gateway_stream(
def test_langgraph_server_gateway_values_do_not_duplicate_message_stream():
events = _collect_server_gateway_stream(
[
_root_text_delta("new"),
_root_message_finish(),
@@ -857,8 +866,8 @@ async def test_langgraph_server_gateway_values_do_not_duplicate_message_stream()
]
async def test_langgraph_server_gateway_ignores_non_root_value_messages():
events = await _collect_server_gateway_stream(
def test_langgraph_server_gateway_ignores_non_root_value_messages():
events = _collect_server_gateway_stream(
[
_value_snapshot(
[{"type": "ai", "content": "subagent text", "id": "subagent-ai"}],
@@ -871,7 +880,7 @@ async def test_langgraph_server_gateway_ignores_non_root_value_messages():
assert events[-1] == {"type": "done", "content": "", "response": ""}
async def test_langgraph_server_gateway_emits_state_interrupt_before_done():
def test_langgraph_server_gateway_emits_state_interrupt_before_done():
stream = FakeLangGraphThreadStream(
"abc12345",
events=[],
@@ -921,7 +930,7 @@ async def test_langgraph_server_gateway_emits_state_interrupt_before_done():
)
]
events = await _collect()
events = run_async(_collect())
assert events == [
{
@@ -945,7 +954,7 @@ async def test_langgraph_server_gateway_emits_state_interrupt_before_done():
]
async def test_langgraph_server_gateway_streams_subagent_protocol_events():
def test_langgraph_server_gateway_streams_subagent_protocol_events():
stream = FakeLangGraphThreadStream(
"abc12345",
events=[
@@ -994,7 +1003,7 @@ async def test_langgraph_server_gateway_streams_subagent_protocol_events():
)
]
events = await _collect()
events = run_async(_collect())
assert events == [
{
@@ -1019,7 +1028,7 @@ async def test_langgraph_server_gateway_streams_subagent_protocol_events():
]
async def test_langgraph_server_gateway_resumes_interrupt_with_thread_stream():
def test_langgraph_server_gateway_resumes_interrupt_with_thread_stream():
from langgraph.types import Command
stream = FakeLangGraphThreadStream(
@@ -1049,7 +1058,7 @@ async def test_langgraph_server_gateway_resumes_interrupt_with_thread_stream():
)
]
events = await _collect()
events = run_async(_collect())
assert stream.run.starts == []
assert stream.run.responses == [
+4 -4
View File
@@ -378,7 +378,7 @@ class TestHitlConfig:
class TestInterruptEventParsing:
async def test_interrupt_from_updates_mode(self):
def test_interrupt_from_updates_mode(self):
"""__interrupt__ in updates mode yields interrupt event."""
interrupt_data = {
"__interrupt__": [
@@ -405,7 +405,7 @@ class TestInterruptEventParsing:
protocol_event("updates", interrupt_data),
]
)
events = await collect_events(agent, message="test", thread_id="thread-1")
events = collect_events(agent, message="test", thread_id="thread-1")
types = [e["type"] for e in events]
assert "interrupt" in types
@@ -415,14 +415,14 @@ class TestInterruptEventParsing:
assert interrupt_ev["action_requests"][0]["name"] == "execute"
assert interrupt_ev["interrupt_id"] == "main"
async def test_updates_without_interrupt_skipped(self):
def test_updates_without_interrupt_skipped(self):
"""Regular updates mode data is skipped as before."""
agent = FakeV3Agent(
[
protocol_event("updates", {"some_node": {"key": "value"}}),
]
)
events = await collect_events(agent, message="test", thread_id="thread-1")
events = collect_events(agent, message="test", thread_id="thread-1")
types = [e["type"] for e in events]
assert "interrupt" not in types
+12 -10
View File
@@ -2,6 +2,8 @@
from unittest.mock import MagicMock, patch
from tests.conftest import run_async as _run
def _ctx():
from EvoScientist.commands.base import CommandContext
@@ -12,15 +14,15 @@ def _ctx():
class TestInstallSkill:
async def test_usage_message_when_no_args(self):
def test_usage_message_when_no_args(self):
from EvoScientist.commands.implementation.skills import InstallSkill
ctx, ui = _ctx()
await InstallSkill().execute(ctx, [])
_run(InstallSkill().execute(ctx, []))
msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Usage:" in m for m in msgs)
async def test_happy_path(self):
def test_happy_path(self):
from EvoScientist.commands.implementation.skills import InstallSkill
ctx, ui = _ctx()
@@ -33,21 +35,21 @@ class TestInstallSkill:
"path": "/tmp/demo",
},
):
await InstallSkill().execute(ctx, ["./some-path"])
_run(InstallSkill().execute(ctx, ["./some-path"]))
msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Installed: demo-skill" in m for m in msgs)
class TestUninstallSkill:
async def test_usage_message_when_no_args(self):
def test_usage_message_when_no_args(self):
from EvoScientist.commands.implementation.skills import UninstallSkill
ctx, ui = _ctx()
await UninstallSkill().execute(ctx, [])
_run(UninstallSkill().execute(ctx, []))
msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Usage:" in m for m in msgs)
async def test_uninstall_success(self):
def test_uninstall_success(self):
from EvoScientist.commands.implementation.skills import UninstallSkill
ctx, ui = _ctx()
@@ -55,11 +57,11 @@ class TestUninstallSkill:
"EvoScientist.tools.skills_manager.uninstall_skill",
return_value={"success": True},
):
await UninstallSkill().execute(ctx, ["demo-skill"])
_run(UninstallSkill().execute(ctx, ["demo-skill"]))
msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Uninstalled: demo-skill" in m for m in msgs)
async def test_uninstall_failure(self):
def test_uninstall_failure(self):
from EvoScientist.commands.implementation.skills import UninstallSkill
ctx, ui = _ctx()
@@ -67,6 +69,6 @@ class TestUninstallSkill:
"EvoScientist.tools.skills_manager.uninstall_skill",
return_value={"success": False, "error": "not found"},
):
await UninstallSkill().execute(ctx, ["missing"])
_run(UninstallSkill().execute(ctx, ["missing"]))
msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Failed: not found" in m for m in msgs)
+10 -10
View File
@@ -19,7 +19,7 @@ async def _agen(items):
yield item
async def test_writes_each_event_as_one_jsonl_line():
def test_writes_each_event_as_one_jsonl_line(run_async):
"""Each event dict is serialized to exactly one JSON line, in order."""
events = [
{"type": "thinking", "content": "hmm", "id": 0},
@@ -34,7 +34,7 @@ async def test_writes_each_event_as_one_jsonl_line():
]
out = io.StringIO()
await write_events_as_json(_agen(events), out)
run_async(write_events_as_json(_agen(events), out))
lines = out.getvalue().splitlines()
assert len(lines) == len(events)
@@ -43,7 +43,7 @@ async def test_writes_each_event_as_one_jsonl_line():
assert parsed[2]["args"] == {"path": "a.md"}
async def test_returns_final_response_from_done_event():
def test_returns_final_response_from_done_event(run_async):
"""The sink returns the response text carried by the terminal `done` event."""
events = [
{"type": "text", "content": "partial"},
@@ -51,12 +51,12 @@ async def test_returns_final_response_from_done_event():
]
out = io.StringIO()
result = await write_events_as_json(_agen(events), out)
result = run_async(write_events_as_json(_agen(events), out))
assert result == "the answer"
async def test_non_serializable_arg_does_not_crash_the_stream():
def test_non_serializable_arg_does_not_crash_the_stream(run_async):
"""A non-JSON-serializable value degrades to its str form instead of raising."""
class Weird:
@@ -72,7 +72,7 @@ async def test_non_serializable_arg_does_not_crash_the_stream():
]
out = io.StringIO()
await write_events_as_json(_agen(events), out)
run_async(write_events_as_json(_agen(events), out))
lines = out.getvalue().splitlines()
# Both lines must be valid JSON; the non-serializable value falls back to str.
@@ -80,7 +80,7 @@ async def test_non_serializable_arg_does_not_crash_the_stream():
assert first["args"]["obj"] == "WEIRD"
async def test_stream_json_sources_events_from_gateway():
def test_stream_json_sources_events_from_gateway(run_async):
"""stream_json pulls events from gateway.stream_events(request) and serializes
them — it does not reach past the gateway abstraction."""
seen: dict[str, object] = {}
@@ -98,7 +98,7 @@ async def test_stream_json_sources_events_from_gateway():
return _agen(events)
out = io.StringIO()
result = await stream_json(_FakeGateway(), object(), out=out)
result = run_async(stream_json(_FakeGateway(), object(), out=out))
assert result == "hi"
assert "request" in seen # the request was forwarded to the gateway
@@ -106,7 +106,7 @@ async def test_stream_json_sources_events_from_gateway():
assert types == ["text", "done"]
async def test_stream_json_propagates_gateway_errors():
def test_stream_json_propagates_gateway_errors(run_async):
"""An error from the gateway stream propagates out of stream_json so the CLI
dispatch can turn it into a clean exit."""
@@ -124,4 +124,4 @@ async def test_stream_json_propagates_gateway_errors():
out = io.StringIO()
with pytest.raises(RuntimeError, match="boom"):
await stream_json(_FakeGateway(), object(), out=out)
run_async(stream_json(_FakeGateway(), object(), out=out))
+141
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
from unittest.mock import patch
from langchain_core.messages import AIMessage, HumanMessage
from starlette.testclient import TestClient
from EvoScientist.config import EvoScientistConfig
@@ -161,3 +162,143 @@ def test_ollama_discovery_skipped_when_base_url_absent():
{"name": n, "model_id": m, "provider": p}
for n, m, p in list_models_by_provider()
]
def test_final_answer_extracts_latest_ai_text_blocks():
async def fake_metadata(_thread_id):
return {"updated_at": "2026-07-06T14:14:53+00:00"}
async def fake_messages(_thread_id):
return [
HumanMessage(content="question"),
AIMessage(content="old answer"),
AIMessage(
content=[
{"type": "reasoning", "text": "internal"},
{"type": "text", "text": "Part A"},
{"type": "tool_use", "name": "search"},
{"type": "output_text", "text": "Part B"},
]
),
]
async def fake_runtime(_request, _thread_id):
return {
"found": True,
"complete": True,
"completed_at": "2026-07-06T14:15:00+00:00",
}
with (
patch(
"EvoScientist.langgraph_dev.http._get_thread_metadata_for_http",
new=fake_metadata,
),
patch(
"EvoScientist.langgraph_dev.http._get_thread_messages_for_http",
new=fake_messages,
),
patch(
"EvoScientist.langgraph_dev.http._read_thread_runtime_state",
new=fake_runtime,
),
):
resp = client.get("/api/threads/thread-1/final-answer")
assert resp.status_code == 200
assert resp.json() == {
"content": "Part A\n\nPart B",
"completed_at": "2026-07-06T14:15:00+00:00",
"complete": True,
}
def test_final_answer_skips_tool_selection_json_text():
async def fake_metadata(_thread_id):
return {"updated_at": "2026-07-06T14:14:53+00:00"}
async def fake_messages(_thread_id):
return [
HumanMessage(content="question"),
AIMessage(content="stable answer"),
AIMessage(
content=(
'{"tools":["search_papers","get_abstract"]}'
'{"tools":["web_search_exa"]}'
)
),
]
async def fake_runtime(_request, _thread_id):
return {
"found": True,
"complete": True,
"completed_at": "2026-07-06T14:15:00+00:00",
}
with (
patch(
"EvoScientist.langgraph_dev.http._get_thread_metadata_for_http",
new=fake_metadata,
),
patch(
"EvoScientist.langgraph_dev.http._get_thread_messages_for_http",
new=fake_messages,
),
patch(
"EvoScientist.langgraph_dev.http._read_thread_runtime_state",
new=fake_runtime,
),
):
resp = client.get("/api/threads/thread-1/final-answer")
assert resp.status_code == 200
assert resp.json()["content"] == "stable answer"
def test_final_answer_returns_404_for_unknown_thread():
async def fake_metadata(_thread_id):
return None
with patch(
"EvoScientist.langgraph_dev.http._get_thread_metadata_for_http",
new=fake_metadata,
):
resp = client.get("/api/threads/missing/final-answer")
assert resp.status_code == 404
assert resp.json() == {"error": "thread not found"}
def test_final_answer_does_not_mark_complete_when_runtime_state_fails():
async def fake_metadata(_thread_id):
return {"updated_at": "2026-07-06T14:14:53+00:00"}
async def fake_messages(_thread_id):
return [AIMessage(content="checkpoint answer")]
async def fake_runtime(_request, _thread_id):
raise RuntimeError("langgraph runtime unavailable")
with (
patch(
"EvoScientist.langgraph_dev.http._get_thread_metadata_for_http",
new=fake_metadata,
),
patch(
"EvoScientist.langgraph_dev.http._get_thread_messages_for_http",
new=fake_messages,
),
patch(
"EvoScientist.langgraph_dev.http._read_thread_runtime_state",
new=fake_runtime,
),
):
resp = client.get("/api/threads/thread-1/final-answer")
assert resp.status_code == 200
assert resp.json() == {
"content": "checkpoint answer",
"completed_at": None,
"complete": False,
}
-72
View File
@@ -8,7 +8,6 @@ to be available.
from __future__ import annotations
import dataclasses
import sys
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
@@ -33,77 +32,6 @@ def reset_module_state():
manager._LOG_OFFSET_AT_START = 0
# =============================================================================
# langgraph CLI resolution
# =============================================================================
class TestLanggraphCliResolution:
def _make_executable(self, path):
path.write_text("#!/bin/sh\n", encoding="utf-8")
path.chmod(0o755)
def test_prefers_current_python_environment_over_path(self, tmp_path, monkeypatch):
local_bin = tmp_path / "local" / "bin"
local_bin.mkdir(parents=True)
local_langgraph = local_bin / "langgraph"
self._make_executable(local_langgraph)
path_bin = tmp_path / "path" / "bin"
path_bin.mkdir(parents=True)
path_langgraph = path_bin / "langgraph"
self._make_executable(path_langgraph)
monkeypatch.setattr(sys, "executable", str(local_bin / "python"))
monkeypatch.setattr(
manager.shutil,
"which",
lambda command: str(path_langgraph) if command == "langgraph" else None,
)
assert manager._langgraph_exe() == str(local_langgraph)
def test_falls_back_to_path_when_environment_binary_missing(
self, tmp_path, monkeypatch
):
path_bin = tmp_path / "path" / "bin"
path_bin.mkdir(parents=True)
path_langgraph = path_bin / "langgraph"
self._make_executable(path_langgraph)
monkeypatch.setattr(
sys, "executable", str(tmp_path / "local" / "bin" / "python")
)
monkeypatch.setattr(
manager.shutil,
"which",
lambda command: str(path_langgraph) if command == "langgraph" else None,
)
assert manager._langgraph_exe() == str(path_langgraph)
def test_checks_windows_suffix_next_to_current_python(self, tmp_path, monkeypatch):
scripts_dir = tmp_path / "Scripts"
scripts_dir.mkdir()
local_langgraph = scripts_dir / "langgraph.exe"
self._make_executable(local_langgraph)
path_bin = tmp_path / "path" / "bin"
path_bin.mkdir(parents=True)
path_langgraph = path_bin / "langgraph.exe"
self._make_executable(path_langgraph)
monkeypatch.setattr(sys, "executable", str(scripts_dir / "python.exe"))
monkeypatch.setattr(manager.os, "name", "nt", raising=False)
monkeypatch.setattr(
manager.shutil,
"which",
lambda command: str(path_langgraph) if command == "langgraph" else None,
)
assert manager._langgraph_exe() == str(local_langgraph)
# =============================================================================
# is_langgraph_dev_running
# =============================================================================
+5 -169
View File
@@ -1,6 +1,5 @@
"""Tests for EvoScientist LLM module."""
from types import SimpleNamespace
from unittest.mock import patch
import pytest
@@ -161,68 +160,6 @@ class TestGetModelInfo:
class TestGetChatModel:
@patch("EvoScientist.llm.models.init_chat_model")
def test_uses_host_model_resolver(self, mock_init):
"""An embedding host can provide model routing without a core dependency."""
from EvoScientist.runtime_integrations import (
configure_runtime_integrations,
reset_runtime_integrations,
)
mock_init.return_value = "mock_model"
resolved = SimpleNamespace(
provider_name="relay-a",
model_id="model-a",
protocol="openai",
api_key="sk-host",
base_url="https://relay.example/v1/",
params={"max_tokens": 8192, "_default_headers": {"X-Relay": "a"}},
supports_reasoning=False,
)
configure_runtime_integrations(model_resolver=lambda model, provider: resolved)
try:
assert get_chat_model("alias-a") == "mock_model"
finally:
reset_runtime_integrations()
call_kwargs = mock_init.call_args.kwargs
assert call_kwargs["model"] == "model-a"
assert call_kwargs["model_provider"] == "openai"
assert call_kwargs["api_key"] == "sk-host"
assert call_kwargs["base_url"] == "https://relay.example/v1"
assert call_kwargs["max_tokens"] == 8192
assert call_kwargs["default_headers"] == {"X-Relay": "a"}
assert "reasoning" not in call_kwargs
@patch("EvoScientist.llm.models._patch_openai_compat_content")
@patch("EvoScientist.llm.models.init_chat_model")
def test_host_openai_provider_with_custom_base_uses_compat_patch(
self, mock_init, mock_compat
):
from EvoScientist.runtime_integrations import (
configure_runtime_integrations,
reset_runtime_integrations,
)
model_instance = object()
mock_init.return_value = model_instance
resolved = SimpleNamespace(
provider_name="openai",
model_id="gpt-5.5",
protocol="openai",
api_key="sk-host",
base_url="https://relay.example/v1",
params={},
supports_reasoning=True,
)
configure_runtime_integrations(model_resolver=lambda model, provider: resolved)
try:
get_chat_model("gpt-5.5", provider="openai")
finally:
reset_runtime_integrations()
mock_compat.assert_called_once_with(model_instance, hoist_tool_media=True)
@patch("EvoScientist.llm.models.init_chat_model")
def test_uses_default_model_when_none(self, mock_init):
"""Test that get_chat_model uses default model when model=None."""
@@ -292,39 +229,6 @@ class TestGetChatModel:
assert call_kwargs["temperature"] == 0.7
assert call_kwargs["max_tokens"] == 1000
@patch("EvoScientist.llm.models.init_chat_model")
def test_drops_unsupported_legacy_model_kwargs(self, mock_init):
mock_init.return_value = "mock_model"
get_chat_model(
"gpt-5-nano",
provider="openai",
sanitize_openai_sdk_headers=True,
model_kwargs={"sanitize_openai_sdk_headers": False, "custom": "value"},
)
call_kwargs = mock_init.call_args.kwargs
assert "sanitize_openai_sdk_headers" not in call_kwargs
assert call_kwargs["model_kwargs"] == {"custom": "value"}
@patch("EvoScientist.llm.models.init_chat_model")
def test_explicit_credentials_override_environment(self, mock_init, monkeypatch):
"""Host-provided credentials take precedence over process defaults."""
mock_init.return_value = "mock_model"
monkeypatch.setenv("OPENAI_API_KEY", "sk-environment")
monkeypatch.setenv("OPENAI_BASE_URL", "https://environment.example/v1")
get_chat_model(
"gpt-5-nano",
provider="openai",
api_key="sk-explicit",
base_url="https://explicit.example/v1",
)
call_kwargs = mock_init.call_args.kwargs
assert call_kwargs["api_key"] == "sk-explicit"
assert call_kwargs["base_url"] == "https://explicit.example/v1"
@patch("EvoScientist.llm.models.init_chat_model")
def test_infers_openai_from_gpt_prefix(self, mock_init):
"""Test that OpenAI is inferred from gpt- prefix."""
@@ -1053,52 +957,6 @@ class TestPatchOpenAICompatContent:
model._astream = AsyncMock()
return model
def test_missing_tool_call_ids_are_repaired_without_mutating_history(self):
from langchain_core.messages import AIMessage, ToolMessage
from EvoScientist.llm.patches import _ensure_openai_tool_call_ids
ai = AIMessage(
content=[{"type": "tool_call", "id": "", "name": "execute", "args": {}}],
tool_calls=[{"id": "", "name": "execute", "args": {}}],
)
tool = ToolMessage(content="ok", tool_call_id="")
normalized = _ensure_openai_tool_call_ids([ai, tool])
call_id = normalized[0].tool_calls[0]["id"]
assert call_id.startswith("call_")
assert normalized[0].content[0]["id"] == call_id
assert normalized[1].tool_call_id == call_id
assert ai.tool_calls[0]["id"] == ""
assert tool.tool_call_id == ""
def test_missing_parallel_tool_call_ids_are_stable_and_ordered(self):
from langchain_core.messages import AIMessage, ToolMessage
from EvoScientist.llm.patches import _ensure_openai_tool_call_ids
messages = [
AIMessage(
id="assistant-1",
content="",
tool_calls=[
{"id": "", "name": "read_file", "args": {}},
{"id": "", "name": "execute", "args": {}},
],
),
ToolMessage(content="file", tool_call_id=""),
ToolMessage(content="command", tool_call_id=""),
]
first = _ensure_openai_tool_call_ids(messages)
second = _ensure_openai_tool_call_ids(messages)
call_ids = [call["id"] for call in first[0].tool_calls]
assert call_ids == [call["id"] for call in second[0].tool_calls]
assert len(set(call_ids)) == 2
assert [message.tool_call_id for message in first[1:]] == call_ids
def test_generate_flattened(self):
from langchain_core.messages import HumanMessage
@@ -1114,6 +972,7 @@ class TestPatchOpenAICompatContent:
called_msgs = orig.call_args[0][0]
assert called_msgs[0].content == "hello"
@pytest.mark.anyio
async def test_agenerate_flattened(self):
from langchain_core.messages import HumanMessage
@@ -1144,6 +1003,7 @@ class TestPatchOpenAICompatContent:
called_msgs = orig.call_args[0][0]
assert called_msgs[0].content == "hello"
@pytest.mark.anyio
async def test_astream_flattened(self):
from langchain_core.messages import HumanMessage
@@ -1184,6 +1044,7 @@ class TestPatchOpenAICompatContent:
called_msgs = orig.call_args[0][0]
assert called_msgs[0].content == [{"type": "text", "text": "see"}, img]
@pytest.mark.anyio
async def test_agenerate_preserves_media(self):
from langchain_core.messages import HumanMessage
@@ -1216,6 +1077,7 @@ class TestPatchOpenAICompatContent:
called_msgs = orig.call_args[0][0]
assert called_msgs[0].content == [{"type": "text", "text": "see"}, img]
@pytest.mark.anyio
async def test_astream_preserves_media(self):
from langchain_core.messages import HumanMessage
@@ -1688,6 +1550,7 @@ class TestNoVisionFallback:
assert out == ["x", "y"]
assert len(calls) == 2
@pytest.mark.anyio
async def test_astream_falls_back(self):
from unittest.mock import MagicMock
@@ -2380,33 +2243,6 @@ class TestPatchOpenrouterStripResponsesReasoning:
class TestAutoConfig:
@patch("EvoScientist.llm.models.init_chat_model")
def test_internal_sentinels_disable_auto_reasoning(self, mock_init, monkeypatch):
"""Internal callers can disable reasoning without leaking sentinels."""
mock_init.return_value = "mock_model"
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
for model, provider in (
("claude-sonnet-4-6", "anthropic"),
("gpt-5-nano", "openai"),
("gemini-2.5-flash", "google-genai"),
("llama3.1:8b", "ollama"),
):
mock_init.reset_mock()
get_chat_model(
model,
provider=provider,
_disable_reasoning=True,
_disable_thinking=True,
)
call_kwargs = mock_init.call_args.kwargs
assert "_disable_reasoning" not in call_kwargs
assert "_disable_thinking" not in call_kwargs
assert "reasoning" not in call_kwargs
assert "thinking" not in call_kwargs
assert "include_thoughts" not in call_kwargs
@patch("EvoScientist.llm.models.init_chat_model")
def test_anthropic_4_5_thinking(self, mock_init, monkeypatch):
"""Anthropic 4-5 models get enabled thinking with budget."""
-129
View File
@@ -1,129 +0,0 @@
import logging
from datetime import UTC, datetime
from io import StringIO
from EvoScientist.logging_config import (
DailyLogFileHandler,
configure_console_logging,
configure_daily_file_logging,
configure_logging,
default_log_dir,
resolve_log_level,
)
def test_daily_log_file_handler_uses_dated_active_file(tmp_path):
handler = DailyLogFileHandler(tmp_path, prefix="evoscientist", retention_days=30)
logger = logging.getLogger("tests.daily_log_file_handler")
logger.handlers.clear()
logger.propagate = False
logger.setLevel(logging.INFO)
logger.addHandler(handler)
logger.info("hello")
handler.close()
today = datetime.now().strftime("%Y-%m-%d")
assert (tmp_path / f"evoscientist-{today}.log").read_text(encoding="utf-8").strip()
def test_daily_log_file_handler_keeps_latest_retention_days(tmp_path):
for day in range(1, 33):
(tmp_path / f"evoscientist-2026-01-{day:02d}.log").write_text(
"x", encoding="utf-8"
)
handler = DailyLogFileHandler(tmp_path, prefix="evoscientist", retention_days=30)
handler._delete_expired_logs()
handler.close()
remaining = sorted(path.name for path in tmp_path.glob("evoscientist-*.log"))
assert len(remaining) == 30
assert remaining[0] == "evoscientist-2026-01-03.log"
def test_configure_daily_file_logging_replaces_matching_handler(tmp_path):
logger = logging.getLogger("tests.configure_daily_file_logging")
logger.handlers.clear()
logger.propagate = False
first = configure_daily_file_logging(logger, log_dir=tmp_path)
second = configure_daily_file_logging(logger, log_dir=tmp_path)
try:
handlers = [h for h in logger.handlers if isinstance(h, DailyLogFileHandler)]
assert handlers == [second]
assert first.stream is None
finally:
for handler in logger.handlers[:]:
logger.removeHandler(handler)
handler.close()
def test_configure_logging_replaces_only_managed_handlers(tmp_path):
logger = logging.getLogger("tests.configure_logging")
logger.handlers.clear()
logger.propagate = False
external = logging.NullHandler()
logger.addHandler(external)
configure_logging(logger, log_dir=tmp_path, level="debug")
configure_logging(logger, log_dir=tmp_path, level="info")
try:
daily_handlers = [h for h in logger.handlers if isinstance(h, DailyLogFileHandler)]
stream_handlers = [
h
for h in logger.handlers
if isinstance(h, logging.StreamHandler)
and not isinstance(h, DailyLogFileHandler)
]
assert external in logger.handlers
assert len(daily_handlers) == 1
assert len(stream_handlers) == 1
assert logger.level == logging.INFO
finally:
for handler in logger.handlers[:]:
logger.removeHandler(handler)
handler.close()
def test_configure_console_logging_emits_to_stream():
logger = logging.getLogger("tests.configure_console_logging")
logger.handlers.clear()
logger.propagate = False
stream = StringIO()
configure_console_logging(logger, level="INFO", stream=stream)
try:
logger.info("hello")
assert "tests.configure_console_logging: hello" in stream.getvalue()
finally:
for handler in logger.handlers[:]:
logger.removeHandler(handler)
handler.close()
def test_resolve_log_level_accepts_alias_numeric_and_fallback():
assert resolve_log_level("warn") == logging.WARNING
assert resolve_log_level("10") == logging.DEBUG
assert resolve_log_level("", default=logging.ERROR) == logging.ERROR
assert resolve_log_level("not-a-level", default=logging.CRITICAL) == logging.CRITICAL
def test_daily_log_file_handler_supports_utc(tmp_path):
handler = DailyLogFileHandler(tmp_path, utc=True)
try:
today_utc = datetime.now(UTC).strftime("%Y-%m-%d")
assert handler.active_log_path.name == f"evoscientist-{today_utc}.log"
finally:
handler.close()
def test_default_log_dir_uses_current_data_dir(monkeypatch, tmp_path):
import EvoScientist.paths as paths
monkeypatch.delenv("EVOSCIENTIST_LOG_DIR", raising=False)
monkeypatch.setattr(paths, "DATA_DIR", tmp_path / "data")
assert default_log_dir() == tmp_path / "data" / "logs"
+18 -23
View File
@@ -1399,7 +1399,9 @@ class TestLoadToolsProgressCallback:
monkeypatch.setattr(lc_client, "MultiServerMCPClient", _FakeClient)
async def test_success_emits_start_then_success_with_tool_count(self, monkeypatch):
def test_success_emits_start_then_success_with_tool_count(self, monkeypatch):
import asyncio
from EvoScientist.mcp.client import _load_tools
events: list[tuple[str, str, str]] = []
@@ -1413,45 +1415,36 @@ class TestLoadToolsProgressCallback:
def record(event, name, detail):
events.append((event, name, detail))
await _load_tools(config, on_progress=record)
asyncio.run(_load_tools(config, on_progress=record))
assert events == [
("start", "srv", ""),
("success", "srv", "3"),
]
async def test_failure_emits_start_then_error_with_detail(self, monkeypatch):
from EvoScientist.mcp import client as mcp_client
def test_failure_emits_start_then_error_with_detail(self, monkeypatch):
import asyncio
from EvoScientist.mcp.client import _load_tools
events: list[tuple[str, str, str]] = []
self._patch_client(monkeypatch, {"srv": RuntimeError("boom")})
monkeypatch.setattr(mcp_client, "_MCP_SERVER_ERRORS", {})
config = {"srv": {"transport": "stdio", "command": "demo"}}
def record(event, name, detail):
events.append((event, name, detail))
await mcp_client._load_tools(config, on_progress=record)
asyncio.run(_load_tools(config, on_progress=record))
assert events == [
("start", "srv", ""),
("error", "srv", "boom"),
]
assert mcp_client.get_mcp_server_errors() == {"srv": "boom"}
async def test_success_clears_previous_server_error(self, monkeypatch):
from EvoScientist.mcp import client as mcp_client
def test_mixed_fleet_reports_each_server_independently(self, monkeypatch):
import asyncio
self._patch_client(monkeypatch, {"srv": ["tool1"]})
monkeypatch.setattr(mcp_client, "_MCP_SERVER_ERRORS", {"srv": "old error"})
config = {"srv": {"transport": "stdio", "command": "demo"}}
await mcp_client._load_tools(config)
assert mcp_client.get_mcp_server_errors() == {}
async def test_mixed_fleet_reports_each_server_independently(self, monkeypatch):
from EvoScientist.mcp.client import _load_tools
events: list[tuple[str, str, str]] = []
@@ -1471,7 +1464,7 @@ class TestLoadToolsProgressCallback:
def record(event, name, detail):
events.append((event, name, detail))
await _load_tools(config, on_progress=record)
asyncio.run(_load_tools(config, on_progress=record))
by_server = {}
for ev, name, detail in events:
@@ -1479,7 +1472,9 @@ class TestLoadToolsProgressCallback:
assert by_server["ok_srv"] == [("start", ""), ("success", "1")]
assert by_server["bad_srv"] == [("start", ""), ("error", "refused")]
async def test_callback_errors_do_not_break_the_load(self, monkeypatch):
def test_callback_errors_do_not_break_the_load(self, monkeypatch):
import asyncio
from EvoScientist.mcp.client import _load_tools
self._patch_client(monkeypatch, {"srv": ["tool1"]})
@@ -1489,10 +1484,10 @@ class TestLoadToolsProgressCallback:
def bad_callback(event, name, detail):
raise RuntimeError("callback bug")
result = await _load_tools(config, on_progress=bad_callback)
result = asyncio.run(_load_tools(config, on_progress=bad_callback))
assert result == {"srv": ["tool1"]}
async def test_semaphore_caps_concurrent_connections(self, monkeypatch):
def test_semaphore_caps_concurrent_connections(self, monkeypatch):
"""Many configured servers must not all spawn at once."""
import asyncio
@@ -1521,7 +1516,7 @@ class TestLoadToolsProgressCallback:
config = {
f"srv{i}": {"transport": "stdio", "command": "demo"} for i in range(10)
}
await mcp_client._load_tools(config)
asyncio.run(mcp_client._load_tools(config))
assert inflight["peak"] <= 3
assert inflight["peak"] > 1 # sanity: we *are* parallelizing
+18 -16
View File
@@ -2,6 +2,8 @@
from unittest.mock import MagicMock, patch
from tests.conftest import run_async as _run
def _ctx():
from EvoScientist.commands.base import CommandContext
@@ -12,16 +14,16 @@ def _ctx():
class TestMCPCommandDispatch:
async def test_no_args_lists(self):
def test_no_args_lists(self):
from EvoScientist.commands.implementation.mcp import MCPCommand
ctx, ui = _ctx()
with patch("EvoScientist.mcp.load_mcp_config", return_value={}):
await MCPCommand().execute(ctx, [])
_run(MCPCommand().execute(ctx, []))
msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("No MCP servers configured" in m for m in msgs)
async def test_list_subcommand(self):
def test_list_subcommand(self):
from EvoScientist.commands.implementation.mcp import MCPCommand
ctx, ui = _ctx()
@@ -29,10 +31,10 @@ class TestMCPCommandDispatch:
"srv1": {"transport": "stdio", "tools": ["foo"], "expose_to": ["main"]},
}
with patch("EvoScientist.mcp.load_mcp_config", return_value=cfg):
await MCPCommand().execute(ctx, ["list"])
_run(MCPCommand().execute(ctx, ["list"]))
ui.mount_renderable.assert_called_once()
async def test_add_subcommand_dispatches(self):
def test_add_subcommand_dispatches(self):
from EvoScientist.commands.implementation.mcp import MCPCommand
ctx, _ui = _ctx()
@@ -46,10 +48,10 @@ class TestMCPCommandDispatch:
return_value={"transport": "stdio"},
) as add_mock,
):
await MCPCommand().execute(ctx, ["add", "srv1", "python"])
_run(MCPCommand().execute(ctx, ["add", "srv1", "python"]))
add_mock.assert_called_once()
async def test_edit_subcommand_dispatches(self):
def test_edit_subcommand_dispatches(self):
from EvoScientist.commands.implementation.mcp import MCPCommand
ctx, _ui = _ctx()
@@ -62,28 +64,28 @@ class TestMCPCommandDispatch:
"EvoScientist.mcp.edit_mcp_server",
) as edit_mock,
):
await MCPCommand().execute(ctx, ["edit", "srv1", "--tools", "bar"])
_run(MCPCommand().execute(ctx, ["edit", "srv1", "--tools", "bar"]))
edit_mock.assert_called_once_with("srv1", tools=["bar"])
async def test_remove_subcommand_success(self):
def test_remove_subcommand_success(self):
from EvoScientist.commands.implementation.mcp import MCPCommand
ctx, ui = _ctx()
with patch("EvoScientist.mcp.remove_mcp_server", return_value=True):
await MCPCommand().execute(ctx, ["remove", "srv1"])
_run(MCPCommand().execute(ctx, ["remove", "srv1"]))
msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Removed MCP server: srv1" in m for m in msgs)
async def test_remove_subcommand_not_found(self):
def test_remove_subcommand_not_found(self):
from EvoScientist.commands.implementation.mcp import MCPCommand
ctx, ui = _ctx()
with patch("EvoScientist.mcp.remove_mcp_server", return_value=False):
await MCPCommand().execute(ctx, ["remove", "missing"])
_run(MCPCommand().execute(ctx, ["remove", "missing"]))
msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Server not found" in m for m in msgs)
async def test_install_delegates_to_install_mcp_command(self):
def test_install_delegates_to_install_mcp_command(self):
"""/mcp install should instantiate InstallMCPCommand and execute it."""
from EvoScientist.commands.implementation.mcp import MCPCommand
@@ -99,13 +101,13 @@ class TestMCPCommandDispatch:
instance.execute = fake_execute
klass.return_value = instance
await MCPCommand().execute(ctx, ["install", "foo"])
_run(MCPCommand().execute(ctx, ["install", "foo"]))
klass.assert_called_once()
async def test_unknown_subcommand_prints_help(self):
def test_unknown_subcommand_prints_help(self):
from EvoScientist.commands.implementation.mcp import MCPCommand
ctx, ui = _ctx()
await MCPCommand().execute(ctx, ["bogus"])
_run(MCPCommand().execute(ctx, ["bogus"]))
msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("MCP commands:" in m for m in msgs)
+34 -34
View File
@@ -5,6 +5,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from tests.conftest import run_async as _run
class TestExtractModelAndProvider:
"""Unit tests for the argument parser helper."""
@@ -78,7 +80,7 @@ class TestExtractModelAndProvider:
class TestModelCommandUnknownModel:
"""Verify error message for unknown models."""
async def test_unknown_model_shows_error(self):
def test_unknown_model_shows_error(self):
from EvoScientist.commands.implementation.model import ModelCommand
cmd = ModelCommand()
@@ -93,7 +95,7 @@ class TestModelCommandUnknownModel:
"EvoScientist.EvoScientist._ensure_config",
return_value=cfg,
):
await cmd.execute(ctx, ["nonexistent-model-xyz"])
_run(cmd.execute(ctx, ["nonexistent-model-xyz"]))
ui.append_system.assert_called_once()
call_args = ui.append_system.call_args
@@ -104,7 +106,7 @@ class TestModelCommandUnknownModel:
class TestModelCommandPickerCancelled:
"""Verify no-op when the interactive picker is cancelled."""
async def test_picker_returns_none(self):
def test_picker_returns_none(self):
from EvoScientist.commands.implementation.model import ModelCommand
cmd = ModelCommand()
@@ -120,7 +122,7 @@ class TestModelCommandPickerCancelled:
"EvoScientist.EvoScientist._ensure_config",
return_value=cfg,
):
await cmd.execute(ctx, [])
_run(cmd.execute(ctx, []))
# No model switch should have happened
ui.append_system.assert_not_called()
@@ -129,7 +131,7 @@ class TestModelCommandPickerCancelled:
class TestModelCommandSwitch:
"""Verify a successful model switch updates config and rebuilds agent."""
async def test_switch_known_model(self):
def test_switch_known_model(self):
from EvoScientist.commands.implementation.model import ModelCommand
cmd = ModelCommand()
@@ -156,7 +158,7 @@ class TestModelCommandSwitch:
return_value=new_agent,
),
):
await cmd.execute(ctx, ["claude-opus-4-8"])
_run(cmd.execute(ctx, ["claude-opus-4-8"]))
# The switch is committed via set_active_config(temp_cfg), not by
# mutating the original cfg object in place.
@@ -174,7 +176,7 @@ class TestModelCommandSwitch:
assert "claude-opus-4-8" in msg
assert "anthropic" in msg
async def test_switch_with_save_flag(self):
def test_switch_with_save_flag(self):
from EvoScientist.commands.implementation.model import ModelCommand
cmd = ModelCommand()
@@ -201,7 +203,7 @@ class TestModelCommandSwitch:
),
patch("EvoScientist.config.settings.set_config_value") as mock_save,
):
await cmd.execute(ctx, ["claude-opus-4-8", "--save"])
_run(cmd.execute(ctx, ["claude-opus-4-8", "--save"]))
# Config file should be updated
mock_save.assert_any_call("model", "claude-opus-4-8")
@@ -211,7 +213,7 @@ class TestModelCommandSwitch:
msg = ui.append_system.call_args[0][0]
assert "saved to config" in msg
async def test_switch_without_save_flag_does_not_persist(self):
def test_switch_without_save_flag_does_not_persist(self):
from EvoScientist.commands.implementation.model import ModelCommand
cmd = ModelCommand()
@@ -238,7 +240,7 @@ class TestModelCommandSwitch:
),
patch("EvoScientist.config.settings.set_config_value") as mock_save,
):
await cmd.execute(ctx, ["claude-opus-4-8"])
_run(cmd.execute(ctx, ["claude-opus-4-8"]))
# Config file should NOT be updated
mock_save.assert_not_called()
@@ -251,7 +253,7 @@ class TestModelCommandSwitch:
class TestModelCommandFailure:
"""Verify error handling when chat-model construction raises."""
async def test_build_chat_model_error(self):
def test_build_chat_model_error(self):
from EvoScientist.commands.implementation.model import ModelCommand
cmd = ModelCommand()
@@ -274,7 +276,7 @@ class TestModelCommandFailure:
side_effect=RuntimeError("API key missing"),
) as mock_build,
):
await cmd.execute(ctx, ["claude-opus-4-8"])
_run(cmd.execute(ctx, ["claude-opus-4-8"]))
mock_build.assert_called_once()
ui.append_system.assert_called_once()
@@ -444,7 +446,7 @@ class TestApplyModelIntegration:
pair so we can assert on identity.
"""
async def test_new_agent_is_bound_to_newly_selected_model(self, evo_module_state):
def test_new_agent_is_bound_to_newly_selected_model(self, evo_module_state):
from EvoScientist.commands.implementation.model import ModelCommand
from EvoScientist.config.settings import EvoScientistConfig
@@ -500,7 +502,7 @@ class TestApplyModelIntegration:
),
):
cmd = ModelCommand()
await cmd._apply_model(ctx, "minimax-m2.7", "openrouter")
_run(cmd._apply_model(ctx, "minimax-m2.7", "openrouter"))
# The agent produced by _apply_model must be bound to the
# NEWLY requested model, threaded in via chat_model=.
@@ -537,9 +539,7 @@ class TestApplyModelPreservesConfigByReference:
switch (the held object stops being the active ``_config`` after the first).
"""
async def test_held_config_reference_tracks_repeated_switches(
self, evo_module_state
):
def test_held_config_reference_tracks_repeated_switches(self, evo_module_state):
from EvoScientist.commands.implementation.model import ModelCommand
from EvoScientist.config.settings import EvoScientistConfig
@@ -592,7 +592,7 @@ class TestApplyModelPreservesConfigByReference:
("minimax-m2.7", "openrouter"),
("claude-sonnet-4-6", "anthropic"),
]:
await cmd._apply_model(ctx, model, provider)
_run(cmd._apply_model(ctx, model, provider))
# The held reference must reflect the LATEST switch on every
# iteration — not just the first — and stay the active config.
assert agent_holder["config"].model == model
@@ -610,7 +610,7 @@ class TestModelCommandLoadAgentFailure:
the ordering could silently regress (e.g. if ``_apply_model`` were
reordered to call ``set_chat_model`` first)."""
async def test_load_agent_error_is_transactional(self):
def test_load_agent_error_is_transactional(self):
from EvoScientist.commands.implementation.model import ModelCommand
cmd = ModelCommand()
@@ -646,7 +646,7 @@ class TestModelCommandLoadAgentFailure:
# Pass ``--save`` to strengthen the assertion: if the ordering
# ever regresses, ``set_config_value`` would be called with
# stale data.
await cmd.execute(ctx, ["claude-opus-4-8", "--save"])
_run(cmd.execute(ctx, ["claude-opus-4-8", "--save"]))
# _load_agent was attempted (transactional first step).
mock_load.assert_called_once()
@@ -677,7 +677,7 @@ class TestApplyModelLoadAgentFailureTransactional:
downstream setters never run on failure.
"""
async def test_globals_unchanged_when_load_agent_raises(self, evo_module_state):
def test_globals_unchanged_when_load_agent_raises(self, evo_module_state):
from EvoScientist.commands.implementation.model import ModelCommand
from EvoScientist.config.settings import EvoScientistConfig
@@ -721,7 +721,7 @@ class TestApplyModelLoadAgentFailureTransactional:
),
):
cmd = ModelCommand()
await cmd._apply_model(ctx, "minimax-m2.7", "openrouter")
_run(cmd._apply_model(ctx, "minimax-m2.7", "openrouter"))
# All four globals are unchanged — nothing was committed.
assert mod._config is cfg
@@ -753,7 +753,7 @@ class TestModelCommandOllamaPicker:
ctx.ui = ui
return ctx, cfg, ui
async def test_picker_entries_include_detected_ollama_models(self):
def test_picker_entries_include_detected_ollama_models(self):
"""When Ollama is reachable, detected models appear in entries with
provider='ollama' and the Custom sentinel is appended."""
from EvoScientist.commands.implementation.model import ModelCommand
@@ -773,7 +773,7 @@ class TestModelCommandOllamaPicker:
side_effect=fake_discover,
),
):
await ModelCommand().execute(ctx, [])
_run(ModelCommand().execute(ctx, []))
entries = ui.wait_for_model_pick.call_args[0][0]
ollama_rows = [(n, mid, p) for (n, mid, p) in entries if p == "ollama"]
@@ -785,7 +785,7 @@ class TestModelCommandOllamaPicker:
"ollama",
) in ollama_rows
async def test_picker_entries_include_sentinel_when_discovery_empty(self):
def test_picker_entries_include_sentinel_when_discovery_empty(self):
"""Daemon unreachable / no models pulled — sentinel is the user's
escape hatch and must always be present."""
from EvoScientist.commands.implementation.model import ModelCommand
@@ -805,7 +805,7 @@ class TestModelCommandOllamaPicker:
side_effect=fake_discover,
),
):
await ModelCommand().execute(ctx, [])
_run(ModelCommand().execute(ctx, []))
entries = ui.wait_for_model_pick.call_args[0][0]
ollama_rows = [(n, mid, p) for (n, mid, p) in entries if p == "ollama"]
@@ -813,7 +813,7 @@ class TestModelCommandOllamaPicker:
("Custom Ollama model...", "__custom_ollama__", "ollama")
]
async def test_picker_skips_ollama_section_when_not_configured(self):
def test_picker_skips_ollama_section_when_not_configured(self):
"""ollama_base_url unset → no discovery call, no ollama entries,
no sentinel (issue non-goal: no implicit localhost detection)."""
from EvoScientist.commands.implementation.model import ModelCommand
@@ -832,13 +832,13 @@ class TestModelCommandOllamaPicker:
discovery,
),
):
await ModelCommand().execute(ctx, [])
_run(ModelCommand().execute(ctx, []))
discovery.assert_not_called()
entries = ui.wait_for_model_pick.call_args[0][0]
assert not any(p == "ollama" for (_, _, p) in entries)
async def test_picker_handles_cfg_without_ollama_base_url_attr(self):
def test_picker_handles_cfg_without_ollama_base_url_attr(self):
"""getattr(cfg, 'ollama_base_url', None) fallback: old configs
(or SimpleNamespace test fixtures) may not carry the attribute
at all. Must not raise AttributeError, must not probe."""
@@ -864,13 +864,13 @@ class TestModelCommandOllamaPicker:
discovery,
),
):
await ModelCommand().execute(ctx, [])
_run(ModelCommand().execute(ctx, []))
discovery.assert_not_called()
entries = ui.wait_for_model_pick.call_args[0][0]
assert not any(p == "ollama" for (_, _, p) in entries)
async def test_picker_sentinel_result_is_treated_as_cancel(self):
def test_picker_sentinel_result_is_treated_as_cancel(self):
"""Defense-in-depth: if the widget ever returns the sentinel name
itself (shouldn't happen — it should substitute the typed name),
dispatch treats it as a cancel and does NOT call _apply_model."""
@@ -893,12 +893,12 @@ class TestModelCommandOllamaPicker:
),
patch("EvoScientist.cli.agent._load_agent") as load_agent,
):
await ModelCommand().execute(ctx, [])
_run(ModelCommand().execute(ctx, []))
load_agent.assert_not_called()
assert cfg.model == "claude-sonnet-4-6" # unchanged
async def test_picker_applies_detected_ollama_model(self):
def test_picker_applies_detected_ollama_model(self):
"""User picks a live-detected Ollama model → _apply_model is invoked
with (name, "ollama") and the agent is rebuilt."""
from EvoScientist.commands.implementation.model import ModelCommand
@@ -928,7 +928,7 @@ class TestModelCommandOllamaPicker:
return_value=MagicMock(),
),
):
await ModelCommand().execute(ctx, [])
_run(ModelCommand().execute(ctx, []))
# Committed via set_active_config(temp_cfg); original cfg untouched.
set_cfg.assert_called_once()
+27 -22
View File
@@ -20,6 +20,7 @@ from EvoScientist.middleware.model_fallback import (
clear_fallbacks,
set_ui_emit,
)
from tests.conftest import run_async as _run
# ── Helpers ──────────────────────────────────────────────────────
@@ -145,7 +146,7 @@ class TestIsNonFallbackable:
class TestTryFallbacks:
"""End-to-end tests for the fallback chain traversal."""
async def test_first_fallback_succeeds(self):
def test_first_fallback_succeeds(self):
"""When the first fallback model works, return its response."""
add_fallback("fb-model", "fb-provider")
req = _fake_request()
@@ -153,13 +154,13 @@ class TestTryFallbacks:
with patch("EvoScientist.llm.models.get_chat_model") as mock_gcm:
mock_gcm.return_value = MagicMock()
result = await _try_fallbacks(req, invoke, Exception("503 boom"))
result = _run(_try_fallbacks(req, invoke, Exception("503 boom")))
assert result is AI_RESPONSE
invoke.assert_awaited_once()
mock_gcm.assert_called_once_with(model="fb-model", provider="fb-provider")
async def test_skips_failing_fallback_tries_next(self):
def test_skips_failing_fallback_tries_next(self):
"""When the first fallback fails, try the second."""
add_fallback("fb-bad", "prov-a")
add_fallback("fb-good", "prov-b")
@@ -176,12 +177,12 @@ class TestTryFallbacks:
with patch("EvoScientist.llm.models.get_chat_model") as mock_gcm:
mock_gcm.return_value = MagicMock()
result = await _try_fallbacks(req, _invoke, Exception("503 boom"))
result = _run(_try_fallbacks(req, _invoke, Exception("503 boom")))
assert result is AI_RESPONSE
assert call_count == 2
async def test_all_fallbacks_exhausted_raises_last(self):
def test_all_fallbacks_exhausted_raises_last(self):
"""When every fallback fails, re-raise the last exception."""
add_fallback("fb-a", "prov-a")
add_fallback("fb-b", "prov-b")
@@ -201,11 +202,11 @@ class TestTryFallbacks:
with patch("EvoScientist.llm.models.get_chat_model") as mock_gcm:
mock_gcm.return_value = MagicMock()
with pytest.raises(Exception, match="429 from fb-b") as exc_info:
await _try_fallbacks(req, _invoke, Exception("503 primary"))
_run(_try_fallbacks(req, _invoke, Exception("503 primary")))
assert exc_info.value is last_error
async def test_non_fallbackable_in_chain_aborts_immediately(self):
def test_non_fallbackable_in_chain_aborts_immediately(self):
"""A non-fallbackable error from a fallback model aborts the chain."""
add_fallback("fb-a", "prov-a")
add_fallback("fb-b", "prov-b") # should never be reached
@@ -217,7 +218,7 @@ class TestTryFallbacks:
with patch("EvoScientist.llm.models.get_chat_model") as mock_gcm:
mock_gcm.return_value = MagicMock()
with pytest.raises(Exception, match="context_length_exceeded"):
await _try_fallbacks(req, _invoke, Exception("503 primary"))
_run(_try_fallbacks(req, _invoke, Exception("503 primary")))
# get_chat_model should only have been called once (for fb-a),
# fb-b should never be reached.
@@ -232,41 +233,43 @@ class TestTryFallbacks:
class TestGuardAndFallback:
"""Verify that non-fallbackable errors are re-raised before trying the chain."""
async def test_context_overflow_raises_immediately(self):
def test_context_overflow_raises_immediately(self):
add_fallback("fb", "prov")
req = _fake_request()
invoke = AsyncMock()
with pytest.raises(ContextOverflowError):
await _guard_and_fallback(ContextOverflowError("overflow"), req, invoke)
_run(_guard_and_fallback(ContextOverflowError("overflow"), req, invoke))
invoke.assert_not_awaited()
async def test_malformed_400_raises_immediately(self):
def test_malformed_400_raises_immediately(self):
add_fallback("fb", "prov")
req = _fake_request()
invoke = AsyncMock()
with pytest.raises(Exception, match="invalid_request_error"):
await _guard_and_fallback(
Exception("400: invalid_request_error"), req, invoke
_run(
_guard_and_fallback(
Exception("400: invalid_request_error"), req, invoke
)
)
invoke.assert_not_awaited()
async def test_server_error_proceeds_to_fallback(self):
def test_server_error_proceeds_to_fallback(self):
add_fallback("fb", "prov")
req = _fake_request()
invoke = AsyncMock(return_value=AI_RESPONSE)
with patch("EvoScientist.llm.models.get_chat_model") as mock_gcm:
mock_gcm.return_value = MagicMock()
result = await _guard_and_fallback(Exception("503 overloaded"), req, invoke)
result = _run(_guard_and_fallback(Exception("503 overloaded"), req, invoke))
assert result is AI_RESPONSE
invoke.assert_awaited_once()
async def test_auth_error_proceeds_to_fallback(self):
def test_auth_error_proceeds_to_fallback(self):
"""Auth errors should try the fallback chain (different provider)."""
add_fallback("fb", "other-prov")
req = _fake_request()
@@ -274,8 +277,10 @@ class TestGuardAndFallback:
with patch("EvoScientist.llm.models.get_chat_model") as mock_gcm:
mock_gcm.return_value = MagicMock()
result = await _guard_and_fallback(
Exception("400 Bad Request: invalid_api_key"), req, invoke
result = _run(
_guard_and_fallback(
Exception("400 Bad Request: invalid_api_key"), req, invoke
)
)
assert result is AI_RESPONSE
@@ -290,7 +295,7 @@ class TestGuardAndFallback:
class TestUiEmit:
"""Verify that fallback events are surfaced via the registered callback."""
async def test_emit_captures_messages(self):
def test_emit_captures_messages(self):
add_fallback("fb", "prov")
req = _fake_request()
invoke = AsyncMock(return_value=AI_RESPONSE)
@@ -300,14 +305,14 @@ class TestUiEmit:
with patch("EvoScientist.llm.models.get_chat_model") as mock_gcm:
mock_gcm.return_value = MagicMock()
await _try_fallbacks(req, invoke, Exception("503 down"))
_run(_try_fallbacks(req, invoke, Exception("503 down")))
texts = [t for t, _ in messages]
assert any("Primary model failed" in t for t in texts)
assert any("Falling back to fb (prov)" in t for t in texts)
assert any("succeeded" in t for t in texts)
async def test_emit_shows_non_fallbackable_rejection(self):
def test_emit_shows_non_fallbackable_rejection(self):
add_fallback("fb", "prov")
req = _fake_request()
invoke = AsyncMock()
@@ -316,7 +321,7 @@ class TestUiEmit:
set_ui_emit(lambda text, style: messages.append((text, style)))
with pytest.raises(ContextOverflowError):
await _guard_and_fallback(ContextOverflowError("overflow"), req, invoke)
_run(_guard_and_fallback(ContextOverflowError("overflow"), req, invoke))
texts = [t for t, _ in messages]
assert any("not eligible for fallback" in t for t in texts)
+15 -10
View File
@@ -16,6 +16,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from EvoScientist.llm import patches as patches_mod
from tests.conftest import run_async as _run
# =============================================================================
# Helpers
@@ -151,7 +152,7 @@ class TestStartAsyncTaskInjection:
"configurable": {"model": "gpt-5", "model_provider": "openai"}
}
async def test_async_start_injects_config(self, restore_model_passthrough_patch):
def test_async_start_injects_config(self, restore_model_passthrough_patch):
try:
from deepagents.middleware import async_subagents as ds_mod
except ImportError:
@@ -175,10 +176,12 @@ class TestStartAsyncTaskInjection:
"EvoScientist.EvoScientist._ensure_config",
return_value=_stub_cfg(model="claude-haiku-4-5", provider="anthropic"),
):
await tool.coroutine(
description="hi",
subagent_type="writing-agent",
runtime=_runtime_stub(),
_run(
tool.coroutine(
description="hi",
subagent_type="writing-agent",
runtime=_runtime_stub(),
)
)
runs_async.create.assert_awaited_once()
@@ -264,7 +267,7 @@ class TestUpdateAsyncTaskInjection:
"last_updated_at": "2026-05-07T00:00:00Z",
}
async def test_async_update_injects_config(self, restore_model_passthrough_patch):
def test_async_update_injects_config(self, restore_model_passthrough_patch):
"""The async coroutine path must inject config too."""
try:
from deepagents.middleware import async_subagents as ds_mod
@@ -293,10 +296,12 @@ class TestUpdateAsyncTaskInjection:
"EvoScientist.EvoScientist._ensure_config",
return_value=_stub_cfg(model="gpt-5", provider="openai"),
):
await tool.coroutine(
task_id="thread-001",
message="follow up async",
runtime=runtime,
_run(
tool.coroutine(
task_id="thread-001",
message="follow up async",
runtime=runtime,
)
)
runs_async.create.assert_awaited_once()
+6 -4
View File
@@ -2,9 +2,11 @@
from unittest.mock import AsyncMock, MagicMock
from tests.conftest import run_async as _run
class TestNewCommand:
async def test_execute_calls_start_new_session(self):
def test_execute_calls_start_new_session(self):
from EvoScientist.commands.base import CommandContext
from EvoScientist.commands.implementation.session import NewCommand
@@ -16,7 +18,7 @@ class TestNewCommand:
ui=ui,
workspace_dir="/old/ws",
)
await NewCommand().execute(ctx, [])
_run(NewCommand().execute(ctx, []))
ui.start_new_session.assert_awaited_once()
def test_requires_agent_false(self):
@@ -24,7 +26,7 @@ class TestNewCommand:
assert NewCommand().requires_agent is False
async def test_no_agent_access(self):
def test_no_agent_access(self):
"""Command body must not touch ctx.agent (it's still loading)."""
from EvoScientist.commands.base import CommandContext
from EvoScientist.commands.implementation.session import NewCommand
@@ -33,4 +35,4 @@ class TestNewCommand:
ui.start_new_session = AsyncMock()
ctx = CommandContext(agent=None, thread_id="tid", ui=ui)
# No AttributeError even though ctx.agent is None
await NewCommand().execute(ctx, [])
_run(NewCommand().execute(ctx, []))
+64 -49
View File
@@ -1655,7 +1655,9 @@ def test_turn_compaction_uses_latest_user_turn_only():
]
async def test_lifecycle_schedules_turn_worker_without_awaiting(tmp_path, monkeypatch):
def test_lifecycle_schedules_turn_worker_without_awaiting(
tmp_path, monkeypatch, run_async
):
memory_dir = tmp_path / "memories"
workspace_dir = tmp_path / "workspace"
calls = []
@@ -1680,18 +1682,21 @@ async def test_lifecycle_schedules_turn_worker_without_awaiting(tmp_path, monkey
)
runtime = _runtime("thread-1")
state: AgentState[object] = {
"messages": [
HumanMessage("previous turn"),
AIMessage("previous answer"),
HumanMessage("hi"),
AIMessage("done"),
]
}
await middleware.aafter_agent(
state,
runtime,
)
async def run():
state: AgentState[object] = {
"messages": [
HumanMessage("previous turn"),
AIMessage("previous answer"),
HumanMessage("hi"),
AIMessage("done"),
]
}
await middleware.aafter_agent(
state,
runtime,
)
run_async(run())
assert len(calls) == 1
request, hooks = calls[0]
@@ -2177,9 +2182,10 @@ def test_observation_linker_does_not_launch_when_observations_disabled(
launch_call.assert_not_called()
async def test_async_observation_linker_does_not_launch_when_observations_disabled(
def test_async_observation_linker_does_not_launch_when_observations_disabled(
tmp_path,
monkeypatch,
run_async,
):
context = _linker_context(
memory_dir=tmp_path / "memories",
@@ -2194,7 +2200,7 @@ async def test_async_observation_linker_does_not_launch_when_observations_disabl
launch_call = MagicMock()
monkeypatch.setattr(memory_launch, "alaunch_background_run", launch_call)
run = await memory_launch.alaunch_observation_linker(context)
run = run_async(memory_launch.alaunch_observation_linker(context))
assert run is None
launch_call.assert_not_called()
@@ -2329,8 +2335,8 @@ def test_sync_memory_worker_watcher_untracks_without_counting_on_poll_abort(
assert status.observations_recorded == 0
async def test_async_memory_worker_watcher_untracks_without_counting_on_poll_abort(
tmp_path, monkeypatch
def test_async_memory_worker_watcher_untracks_without_counting_on_poll_abort(
tmp_path, monkeypatch, run_async
):
memory_dir = tmp_path / "memories"
_mark_worker_started(memory_dir)
@@ -2342,12 +2348,14 @@ async def test_async_memory_worker_watcher_untracks_without_counting_on_poll_abo
async def get(self, **_kwargs):
raise RuntimeError("poll failed")
await background_runs.awatch_background_run(
SimpleNamespace(runs=_Runs()),
thread_id="worker-thread",
run_id="run-1",
hooks=memory_launch._memory_worker_launch_hooks(memory_dir),
watcher_config=_fast_watcher_config(max_poll_failures=1),
run_async(
background_runs.awatch_background_run(
SimpleNamespace(runs=_Runs()),
thread_id="worker-thread",
run_id="run-1",
hooks=memory_launch._memory_worker_launch_hooks(memory_dir),
watcher_config=_fast_watcher_config(max_poll_failures=1),
)
)
status = worker_activity.memory_worker_status()
assert status.is_running is False
@@ -2355,8 +2363,8 @@ async def test_async_memory_worker_watcher_untracks_without_counting_on_poll_abo
assert status.observations_recorded == 0
async def test_async_memory_worker_watcher_counts_completion_under_blockbuster(
tmp_path,
def test_async_memory_worker_watcher_counts_completion_under_blockbuster(
tmp_path, run_async
):
memory_dir = tmp_path / "memories"
_mark_worker_started(memory_dir)
@@ -2368,17 +2376,20 @@ async def test_async_memory_worker_watcher_counts_completion_under_blockbuster(
async def get(self, **_kwargs):
return {"status": "success"}
blocker = BlockBuster(scanned_modules=[memory_worker, worker_activity])
blocker.activate()
try:
await background_runs.awatch_background_run(
SimpleNamespace(runs=_Runs()),
thread_id="worker-thread",
run_id="run-1",
hooks=memory_launch._memory_worker_launch_hooks(memory_dir),
)
finally:
blocker.deactivate()
async def run():
blocker = BlockBuster(scanned_modules=[memory_worker, worker_activity])
blocker.activate()
try:
await background_runs.awatch_background_run(
SimpleNamespace(runs=_Runs()),
thread_id="worker-thread",
run_id="run-1",
hooks=memory_launch._memory_worker_launch_hooks(memory_dir),
)
finally:
blocker.deactivate()
run_async(run())
status = worker_activity.memory_worker_status()
assert status.is_running is False
assert status.profile_updates == 1
@@ -2516,7 +2527,7 @@ def test_memory_worker_marks_active_status(tmp_path, monkeypatch):
assert status.observations_recorded == 1
async def test_async_memory_worker_offloads_blocking_work(tmp_path, monkeypatch):
def test_async_memory_worker_offloads_blocking_work(tmp_path, monkeypatch, run_async):
monkeypatch.setattr(
background_runs, "default_background_run_url", lambda: "http://x"
)
@@ -2558,18 +2569,22 @@ async def test_async_memory_worker_offloads_blocking_work(tmp_path, monkeypatch)
spawned: list[background_runs.BackgroundRun] = []
event_loop_thread = threading.get_ident()
context = _memory_source_context(
memory_dir=tmp_path / "memories",
workspace_dir=tmp_path / "workspace",
trajectory=[{"role": "human", "content": "hi"}],
)
request = memory_launch.memory_worker_launch_request(context)
await background_runs.alaunch_background_run(
request,
hooks=memory_launch._memory_worker_launch_hooks(tmp_path / "memories"),
spawn_status_watcher=spawned.append,
)
async def run():
event_loop_thread = threading.get_ident()
context = _memory_source_context(
memory_dir=tmp_path / "memories",
workspace_dir=tmp_path / "workspace",
trajectory=[{"role": "human", "content": "hi"}],
)
request = memory_launch.memory_worker_launch_request(context)
await background_runs.alaunch_background_run(
request,
hooks=memory_launch._memory_worker_launch_hooks(tmp_path / "memories"),
spawn_status_watcher=spawned.append,
)
return event_loop_thread
event_loop_thread = run_async(run())
assert [name for name, _thread_id in call_threads] == ["health", "snapshot"]
assert all(thread_id != event_loop_thread for _name, thread_id in call_threads)
assert worker_activity.memory_worker_status().is_running is True
+21 -20
View File
@@ -17,6 +17,7 @@ from EvoScientist.llm.ollama_discovery import (
discover_ollama_models,
validate_ollama_connection,
)
from tests.conftest import run_async as _run
class TestValidateOllamaConnection:
@@ -70,17 +71,17 @@ class TestValidateOllamaConnection:
class TestDiscoverOllamaModels:
"""Async probe — contract: never raise, return list[str]."""
async def test_empty_base_url_returns_empty_without_http(self):
def test_empty_base_url_returns_empty_without_http(self):
# No HTTP call should be made for an empty base_url — verified by
# the fact that no mock is set up and the test completes.
names = await discover_ollama_models("")
names = _run(discover_ollama_models(""))
assert names == []
async def test_none_base_url_returns_empty(self):
names = await discover_ollama_models(None)
def test_none_base_url_returns_empty(self):
names = _run(discover_ollama_models(None))
assert names == []
async def test_200_returns_names(self):
def test_200_returns_names(self):
async def fake_get(self, url):
resp = MagicMock()
resp.status_code = 200
@@ -92,10 +93,10 @@ class TestDiscoverOllamaModels:
return resp
with patch.object(httpx.AsyncClient, "get", fake_get):
names = await discover_ollama_models("http://localhost:11434")
names = _run(discover_ollama_models("http://localhost:11434"))
assert names == ["llama3.3:latest", "qwen3:8b"]
async def test_strips_entries_without_name(self):
def test_strips_entries_without_name(self):
async def fake_get(self, url):
resp = MagicMock()
resp.status_code = 200
@@ -111,36 +112,36 @@ class TestDiscoverOllamaModels:
return resp
with patch.object(httpx.AsyncClient, "get", fake_get):
names = await discover_ollama_models("http://localhost:11434")
names = _run(discover_ollama_models("http://localhost:11434"))
assert names == ["llama3.3"]
async def test_timeout_returns_empty(self):
def test_timeout_returns_empty(self):
async def fake_get(self, url):
raise httpx.TimeoutException("timed out")
with patch.object(httpx.AsyncClient, "get", fake_get):
names = await discover_ollama_models("http://localhost:11434")
names = _run(discover_ollama_models("http://localhost:11434"))
assert names == []
async def test_connect_error_returns_empty(self):
def test_connect_error_returns_empty(self):
async def fake_get(self, url):
raise httpx.ConnectError("refused")
with patch.object(httpx.AsyncClient, "get", fake_get):
names = await discover_ollama_models("http://localhost:11434")
names = _run(discover_ollama_models("http://localhost:11434"))
assert names == []
async def test_non_200_returns_empty(self):
def test_non_200_returns_empty(self):
async def fake_get(self, url):
resp = MagicMock()
resp.status_code = 500
return resp
with patch.object(httpx.AsyncClient, "get", fake_get):
names = await discover_ollama_models("http://localhost:11434")
names = _run(discover_ollama_models("http://localhost:11434"))
assert names == []
async def test_malformed_json_returns_empty(self):
def test_malformed_json_returns_empty(self):
async def fake_get(self, url):
resp = MagicMock()
resp.status_code = 200
@@ -148,10 +149,10 @@ class TestDiscoverOllamaModels:
return resp
with patch.object(httpx.AsyncClient, "get", fake_get):
names = await discover_ollama_models("http://localhost:11434")
names = _run(discover_ollama_models("http://localhost:11434"))
assert names == []
async def test_missing_models_key_returns_empty(self):
def test_missing_models_key_returns_empty(self):
async def fake_get(self, url):
resp = MagicMock()
resp.status_code = 200
@@ -159,10 +160,10 @@ class TestDiscoverOllamaModels:
return resp
with patch.object(httpx.AsyncClient, "get", fake_get):
names = await discover_ollama_models("http://localhost:11434")
names = _run(discover_ollama_models("http://localhost:11434"))
assert names == []
async def test_trailing_slash_stripped_from_url(self):
def test_trailing_slash_stripped_from_url(self):
called = {}
async def fake_get(self, url):
@@ -173,7 +174,7 @@ class TestDiscoverOllamaModels:
return resp
with patch.object(httpx.AsyncClient, "get", fake_get):
await discover_ollama_models("http://localhost:11434/")
_run(discover_ollama_models("http://localhost:11434/"))
assert called["url"] == "http://localhost:11434/api/tags"
-309
View File
@@ -184,38 +184,6 @@ class TestSharedConstantsAlignment:
)
class TestOAuthModeReconcile:
def test_reconcile_preserves_auxiliary_openai_oauth(self):
from EvoScientist.config.onboard.wizard import _reconcile_oauth_modes
config = EvoScientistConfig(
provider="minimax",
auxiliary_provider="openai",
auxiliary_model="gpt-5.5",
openai_auth_mode="oauth",
anthropic_auth_mode="oauth",
)
_reconcile_oauth_modes(config)
assert config.openai_auth_mode == "oauth"
assert config.anthropic_auth_mode == "api_key"
def test_reconcile_preserves_auxiliary_provider_without_model(self):
from EvoScientist.config.onboard.wizard import _reconcile_oauth_modes
config = EvoScientistConfig(
provider="minimax",
auxiliary_provider="openai",
auxiliary_model="",
openai_auth_mode="oauth",
)
_reconcile_oauth_modes(config)
assert config.openai_auth_mode == "oauth"
# =============================================================================
# Test render_progress
# =============================================================================
@@ -418,109 +386,6 @@ class TestStepProvider:
_step_provider(config)
class TestStepOAuthAuthMode:
@pytest.mark.parametrize(
(
"step_name",
"config_attr",
"provider_label",
"oauth_choice_label",
"ccproxy_provider",
"status_label",
"question_label",
"login_prompt",
),
[
(
"_step_anthropic_auth_mode",
"anthropic_auth_mode",
"Anthropic",
"Claude Code OAuth",
"claude_api",
"OAuth",
"Authentication mode",
"Log in to Claude now?",
),
(
"_step_openai_auth_mode",
"openai_auth_mode",
"OpenAI",
"Codex OAuth",
"codex",
"Codex OAuth",
"OpenAI authentication mode",
"Log in to Codex now?",
),
],
)
def test_oauth_wrappers_use_provider_specific_ccproxy_flow(
self,
step_name,
config_attr,
provider_label,
oauth_choice_label,
ccproxy_provider,
status_label,
question_label,
login_prompt,
):
"""Anthropic/OpenAI wrappers share flow but keep provider-specific IDs."""
from EvoScientist.config.onboard import steps as onboard_steps
config = EvoScientistConfig(**{config_attr: "oauth"})
select_question = MagicMock()
select_question.ask.return_value = "oauth"
confirm_question = MagicMock()
confirm_question.ask.return_value = True
with (
patch(
"EvoScientist.ccproxy_manager.is_ccproxy_available", return_value=True
),
patch(
"EvoScientist.ccproxy_manager.check_ccproxy_auth",
return_value=(False, "not authenticated"),
) as mock_check_auth,
patch(
"EvoScientist.config.onboard.prompter.install_navigation_keys"
) as mock_nav,
patch(
"EvoScientist.config.onboard.steps.questionary.select",
return_value=select_question,
) as mock_select,
patch(
"EvoScientist.config.onboard.steps.questionary.confirm",
return_value=confirm_question,
) as mock_confirm,
patch(
"EvoScientist.config.onboard.steps._prompt_ccproxy_port"
) as mock_port,
patch("EvoScientist.config.onboard.steps._run_ccproxy_login") as mock_login,
):
result = getattr(onboard_steps, step_name)(config)
assert result == "oauth"
mock_nav.assert_called_once_with(select_question, with_back=True)
mock_port.assert_called_once_with(config)
mock_check_auth.assert_called_once_with(ccproxy_provider)
mock_login.assert_called_once_with(ccproxy_provider, status_label)
select_call = mock_select.call_args
assert select_call.args[0] == f"{question_label} [Esc/← to go back]:"
assert select_call.kwargs["default"] == "oauth"
choice_titles = [
choice.title
for choice in select_call.kwargs["choices"]
if getattr(choice, "value", None) in {"api_key", "oauth"}
]
assert choice_titles == [
f"API Key (direct {provider_label} access)",
f"{oauth_choice_label} (via ccproxy — no API key needed)",
]
mock_confirm.assert_called_once()
assert mock_confirm.call_args.args[0] == login_prompt
class TestStepModel:
def test_returns_selected_model(self):
"""Test that _step_model returns selected model."""
@@ -1401,7 +1266,6 @@ class TestRunOnboard:
"claude-sonnet-4-6", # Model
"assemble", # Auxiliary: Assemble
"openai", # Auxiliary provider (a different company)
"api_key", # Auxiliary OpenAI auth mode
"gpt-5.5", # Auxiliary model
"daemon", # Workspace mode
True, # Show thinking
@@ -1425,185 +1289,12 @@ class TestRunOnboard:
final_config = mock_save.call_args_list[-1].args[0]
assert final_config.auxiliary_provider == "openai"
assert final_config.auxiliary_model == "gpt-5.5"
assert final_config.openai_auth_mode == "api_key"
# The auxiliary provider's key is stored in its per-provider field.
assert final_config.openai_api_key == "sk-aux-openai"
# Main agent is untouched.
assert final_config.provider == "anthropic"
assert final_config.model == "claude-sonnet-4-6"
def test_auxiliary_same_provider_reuses_main_credentials(self):
"""Same-provider co-pilot should not imply separate credentials exist."""
from EvoScientist.config.onboard.wizard import run_onboard
mock_q = MagicMock()
with (
_patch_all_questionary(mock_q),
patch("EvoScientist.config.onboard.wizard.load_config") as mock_load,
patch("EvoScientist.config.onboard.wizard.save_config") as mock_save,
patch("EvoScientist.config.onboard.wizard.console"),
patch("EvoScientist.config.onboard.steps.console"),
):
mock_load.return_value = EvoScientistConfig(
provider="openai",
model="gpt-5.5",
openai_api_key="sk-main-openai",
)
mock_q.select.return_value.ask.side_effect = [
"assemble", # Auxiliary: Assemble
"openai", # Same provider as the main model
"gpt-5.5", # Auxiliary model
]
mock_q.confirm.return_value.ask.side_effect = [True] # Save config
result = run_onboard(
skip_validation=True, only_sections={"auxiliary_model"}
)
assert result is True
final_config = mock_save.call_args_list[-1].args[0]
assert final_config.auxiliary_provider == "openai"
assert final_config.auxiliary_model == "gpt-5.5"
assert final_config.openai_api_key == "sk-main-openai"
mock_q.password.assert_not_called()
assert mock_q.select.return_value.ask.call_count == 3
def test_auxiliary_same_provider_prompts_when_shared_key_missing(self):
"""Same-provider reuse should not hide a missing shared API key."""
from EvoScientist.config.onboard.wizard import run_onboard
mock_q = MagicMock()
with (
_patch_all_questionary(mock_q),
patch("EvoScientist.config.onboard.wizard.load_config") as mock_load,
patch("EvoScientist.config.onboard.wizard.save_config") as mock_save,
patch("EvoScientist.config.onboard.wizard.console"),
patch("EvoScientist.config.onboard.steps.console"),
patch("EvoScientist.config.onboard.helpers.console"),
patch(
"EvoScientist.ccproxy_manager.is_ccproxy_available", return_value=True
),
):
mock_load.return_value = EvoScientistConfig(
provider="openai",
model="gpt-5.5",
openai_auth_mode="api_key",
openai_api_key="",
)
mock_q.select.return_value.ask.side_effect = [
"assemble", # Auxiliary: Assemble
"openai", # Same provider as the main model
"api_key", # Shared OpenAI auth mode
"gpt-5.5", # Auxiliary model
]
mock_q.password.return_value.ask.side_effect = [
"sk-shared-openai",
]
mock_q.confirm.return_value.ask.side_effect = [True] # Save config
result = run_onboard(
skip_validation=True, only_sections={"auxiliary_model"}
)
assert result is True
final_config = mock_save.call_args_list[-1].args[0]
assert final_config.auxiliary_provider == "openai"
assert final_config.auxiliary_model == "gpt-5.5"
assert final_config.openai_auth_mode == "api_key"
assert final_config.openai_api_key == "sk-shared-openai"
mock_q.password.assert_called_once()
assert mock_q.select.return_value.ask.call_count == 4
def test_auxiliary_openai_oauth_skips_api_key(self):
"""Auxiliary OpenAI now uses the shared auth flow and skips keys on OAuth."""
from EvoScientist.config.onboard.wizard import run_onboard
mock_q = MagicMock()
with (
_patch_all_questionary(mock_q),
patch("EvoScientist.config.onboard.wizard.load_config") as mock_load,
patch("EvoScientist.config.onboard.wizard.save_config") as mock_save,
patch("EvoScientist.config.onboard.wizard.console"),
patch("EvoScientist.config.onboard.steps.console"),
patch("EvoScientist.config.onboard.helpers.console"),
patch(
"EvoScientist.ccproxy_manager.is_ccproxy_available", return_value=True
),
patch(
"EvoScientist.ccproxy_manager.check_ccproxy_auth",
return_value=(False, "not authenticated"),
) as mock_auth,
):
mock_load.return_value = EvoScientistConfig()
mock_q.select.return_value.ask.side_effect = [
"assemble", # Auxiliary: Assemble
"openai", # Auxiliary provider
"oauth", # OpenAI auth mode
"gpt-5.5", # Auxiliary model
]
mock_q.text.return_value.ask.side_effect = [
"", # ccproxy port (keep default)
]
mock_q.confirm.return_value.ask.side_effect = [
False, # Do not log in to Codex now
True, # Save config
]
result = run_onboard(
skip_validation=True, only_sections={"auxiliary_model"}
)
assert result is True
final_config = mock_save.call_args_list[-1].args[0]
assert final_config.auxiliary_provider == "openai"
assert final_config.auxiliary_model == "gpt-5.5"
assert final_config.openai_auth_mode == "oauth"
assert final_config.openai_api_key == ""
mock_q.password.assert_not_called()
mock_auth.assert_called_once_with("codex")
def test_auxiliary_reconfigure_clears_unused_openai_oauth(self):
"""Switching co-pilot away from OpenAI clears stale OpenAI OAuth mode."""
from EvoScientist.config.onboard.wizard import run_onboard
mock_q = MagicMock()
with (
_patch_all_questionary(mock_q),
patch("EvoScientist.config.onboard.wizard.load_config") as mock_load,
patch("EvoScientist.config.onboard.wizard.save_config") as mock_save,
patch("EvoScientist.config.onboard.wizard.console"),
patch("EvoScientist.config.onboard.steps.console"),
patch("EvoScientist.config.onboard.helpers.console"),
):
mock_load.return_value = EvoScientistConfig(
provider="anthropic",
model="claude-sonnet-4-6",
anthropic_auth_mode="oauth",
auxiliary_provider="openai",
auxiliary_model="gpt-5.5",
openai_auth_mode="oauth",
)
mock_q.select.return_value.ask.side_effect = [
"assemble", # Auxiliary: Assemble
"minimax", # Auxiliary provider no longer uses OpenAI
"global", # MiniMax region
"minimax-m2", # Auxiliary model
]
mock_q.password.return_value.ask.side_effect = [
"sk-minimax", # MiniMax API key
]
mock_q.confirm.return_value.ask.side_effect = [True] # Save config
result = run_onboard(
skip_validation=True, only_sections={"auxiliary_model"}
)
assert result is True
final_config = mock_save.call_args_list[-1].args[0]
assert final_config.auxiliary_provider == "minimax"
assert final_config.openai_auth_mode == "api_key"
assert final_config.anthropic_auth_mode == "oauth"
def test_auxiliary_custom_provider_collects_base_url(self):
"""Regression for the custom-provider fix: a custom auxiliary provider
collects its base URL (provider -> base URL -> key -> model order)."""
-59
View File
@@ -21,7 +21,6 @@ def _restore_paths():
"GLOBAL_MEMORIES_DIR": paths.GLOBAL_MEMORIES_DIR,
"USER_SKILLS_DIR": paths.USER_SKILLS_DIR,
"_active_workspace": paths._active_workspace,
"_EVOSCIENTIST_DATA_ROOT": paths._EVOSCIENTIST_DATA_ROOT,
}
yield
paths.WORKSPACE_ROOT = orig["WORKSPACE_ROOT"]
@@ -33,7 +32,6 @@ def _restore_paths():
paths.GLOBAL_MEMORIES_DIR = orig["GLOBAL_MEMORIES_DIR"]
paths.USER_SKILLS_DIR = orig["USER_SKILLS_DIR"]
paths._active_workspace = orig["_active_workspace"]
paths._EVOSCIENTIST_DATA_ROOT = orig["_EVOSCIENTIST_DATA_ROOT"]
class TestSetWorkspaceRoot:
@@ -142,63 +140,6 @@ class TestDataDir:
assert paths.GLOBAL_MEMORIES_DIR == paths.DATA_DIR / "memories"
class TestGatewayDataDirs:
def test_evoscientist_root_prefers_home_override(self, tmp_path, monkeypatch):
home = tmp_path / "runtime-home"
monkeypatch.setenv("EVOSCIENTIST_HOME", str(home))
assert paths.evoscientist_root() == home.resolve()
def test_evoscientist_root_falls_back_to_data_dir(self, tmp_path, monkeypatch):
data_dir = tmp_path / "app-data"
monkeypatch.delenv("EVOSCIENTIST_HOME", raising=False)
monkeypatch.setattr(paths, "DATA_DIR", data_dir)
assert paths.evoscientist_root() == data_dir.resolve()
def test_data_root_respects_environment_override(self, tmp_path, monkeypatch):
data_root = tmp_path / "web-data"
monkeypatch.setenv("EVOSCIENTIST_DATA_ROOT", str(data_root))
paths._EVOSCIENTIST_DATA_ROOT = None
assert paths._data_root() == data_root.resolve()
def test_user_thread_and_global_dirs_are_created(self, tmp_path, monkeypatch):
data_root = tmp_path / "web-data"
monkeypatch.setenv("EVOSCIENTIST_DATA_ROOT", str(data_root))
paths._EVOSCIENTIST_DATA_ROOT = None
user_dir = paths.user_data_dir("user-a")
thread_dir = paths.thread_data_dir("user-a", "thread-1")
shared_dir = paths.global_data_dir("user-a")
assert user_dir == data_root / "user-a"
assert thread_dir == user_dir / "thread-1"
assert shared_dir == user_dir / "__global__"
assert user_dir.is_dir()
assert thread_dir.is_dir()
assert shared_dir.is_dir()
def test_iter_user_data_dirs_yields_directories_only(self, tmp_path, monkeypatch):
data_root = tmp_path / "web-data"
monkeypatch.setenv("EVOSCIENTIST_DATA_ROOT", str(data_root))
paths._EVOSCIENTIST_DATA_ROOT = None
paths.user_data_dir("user-a")
paths.user_data_dir("user-b")
(data_root / "metadata.json").write_text("{}", encoding="utf-8")
assert {path.name for path in paths.iter_user_data_dirs()} == {
"user-a",
"user-b",
}
def test_uploads_dir_uses_evoscientist_root(self, tmp_path, monkeypatch):
home = tmp_path / "runtime-home"
monkeypatch.setenv("EVOSCIENTIST_HOME", str(home))
assert paths.uploads_dir() == home.resolve() / "uploads"
class TestLegacySessionsDbMigration:
"""Tests for migrate_legacy_sessions_db() — transitional helper.
+6 -4
View File
@@ -79,13 +79,14 @@ class TestPickSkillsInteractive:
class TestInstallSkillsHandlesEmpty:
"""InstallSkills.execute must distinguish None vs [] from the picker."""
async def test_empty_list_suppresses_cancel_message(self):
def test_empty_list_suppresses_cancel_message(self):
"""When picker returns [], user should NOT see "Browse cancelled"
(the picker already printed its own message)."""
from unittest.mock import AsyncMock
from EvoScientist.commands.base import CommandContext
from EvoScientist.commands.implementation.skills import InstallSkills
from tests.conftest import run_async as _run
ui = MagicMock()
ui.supports_interactive = True
@@ -96,17 +97,18 @@ class TestInstallSkillsHandlesEmpty:
"EvoScientist.tools.skills_manager.fetch_remote_skill_index",
return_value=_INDEX,
):
await InstallSkills().execute(ctx, [])
_run(InstallSkills().execute(ctx, []))
msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert not any("Browse cancelled" in m for m in msgs)
async def test_none_shows_cancel_message(self):
def test_none_shows_cancel_message(self):
"""When picker returns None (actual cancel), user sees the message."""
from unittest.mock import AsyncMock
from EvoScientist.commands.base import CommandContext
from EvoScientist.commands.implementation.skills import InstallSkills
from tests.conftest import run_async as _run
ui = MagicMock()
ui.supports_interactive = True
@@ -117,7 +119,7 @@ class TestInstallSkillsHandlesEmpty:
"EvoScientist.tools.skills_manager.fetch_remote_skill_index",
return_value=_INDEX,
):
await InstallSkills().execute(ctx, [])
_run(InstallSkills().execute(ctx, []))
msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Browse cancelled" in m for m in msgs)
+20 -14
View File
@@ -344,7 +344,9 @@ def test_profile_memory_uses_path_pointers_when_profiles_exceed_budget(
)
async def test_profile_memory_async_path_bootstraps_and_injects(tmp_path, monkeypatch):
def test_profile_memory_async_path_bootstraps_and_injects(
tmp_path, monkeypatch, run_async
):
memories = tmp_path / "memories"
workspace = tmp_path / "workspace"
workspace.mkdir()
@@ -354,7 +356,7 @@ async def test_profile_memory_async_path_bootstraps_and_injects(tmp_path, monkey
return request
middleware = memory_module.create_memory_middleware(str(memories))
await middleware.awrap_model_call(_request(), _handler)
run_async(middleware.awrap_model_call(_request(), _handler))
assert (memories / "profile" / "USER_PROFILE.md").exists()
@@ -397,8 +399,8 @@ def test_profile_memory_read_failure_uses_path_pointers_without_overwriting(
assert soul_path.read_bytes() == original_bytes
async def test_profile_memory_async_path_inlines_content_under_blockbuster(
tmp_path, monkeypatch
def test_profile_memory_async_path_inlines_content_under_blockbuster(
tmp_path, monkeypatch, run_async
):
memories = tmp_path / "memories"
workspace = tmp_path / "workspace"
@@ -423,13 +425,17 @@ async def test_profile_memory_async_path_inlines_content_under_blockbuster(
monkeypatch.setattr(middleware, "_read_profile_memory", tracked_read_profile_memory)
event_loop_thread = threading.get_ident()
blocker = BlockBuster(scanned_modules=memory_module)
blocker.activate()
try:
modified = await middleware.amodify_request(_request())
finally:
blocker.deactivate()
async def run():
event_loop_thread = threading.get_ident()
blocker = BlockBuster(scanned_modules=memory_module)
blocker.activate()
try:
modified = await middleware.amodify_request(_request())
finally:
blocker.deactivate()
return event_loop_thread, modified
event_loop_thread, modified = run_async(run())
assert call_threads
assert all(thread_id != event_loop_thread for thread_id in call_threads)
@@ -528,8 +534,8 @@ def test_profile_memory_uses_explicit_workspace_for_project_profile(
).exists()
async def test_profile_memory_resolves_project_id_once_per_middleware(
tmp_path, monkeypatch
def test_profile_memory_resolves_project_id_once_per_middleware(
tmp_path, monkeypatch, run_async
):
memories = tmp_path / "memories"
workspace = tmp_path / "workspace"
@@ -546,7 +552,7 @@ async def test_profile_memory_resolves_project_id_once_per_middleware(
str(memories), workspace_dir=str(workspace), max_inline_profile_chars=10
)
middleware.modify_request(_request())
await middleware.amodify_request(_request())
run_async(middleware.amodify_request(_request()))
assert calls == [workspace]
assert middleware.project_id == "P-cached-project"
+38 -35
View File
@@ -8,6 +8,7 @@ from EvoScientist.channels.qq.channel import (
QQConfig,
_build_qq_keyboard,
)
from tests.conftest import run_async as _run
class TestQQChannelSend:
@@ -21,7 +22,7 @@ class TestQQChannelSend:
channel._client.api.post_group_message = AsyncMock()
return channel
async def test_send_prefers_native_markdown_for_c2c(self):
def test_send_prefers_native_markdown_for_c2c(self):
channel = self._make_ready_channel()
msg = OutboundMessage(
channel="qq",
@@ -34,7 +35,7 @@ class TestQQChannelSend:
},
)
assert await channel.send(msg) is True
assert _run(channel.send(msg)) is True
channel._client.api.post_c2c_message.assert_awaited_once()
sent = channel._client.api.post_c2c_message.await_args.kwargs
@@ -45,7 +46,7 @@ class TestQQChannelSend:
assert sent["msg_seq"] == 1
assert "content" not in sent
async def test_send_falls_back_to_plain_text_when_markdown_send_fails(self):
def test_send_falls_back_to_plain_text_when_markdown_send_fails(self):
channel = self._make_ready_channel()
channel._trace_event = MagicMock(side_effect=RuntimeError("trace failed"))
channel._client.api.post_c2c_message = AsyncMock(
@@ -62,7 +63,7 @@ class TestQQChannelSend:
},
)
assert await channel.send(msg) is True
assert _run(channel.send(msg)) is True
assert channel._client.api.post_c2c_message.await_count == 2
first = channel._client.api.post_c2c_message.await_args_list[0].kwargs
@@ -79,7 +80,7 @@ class TestQQChannelSend:
# trigger "duplicate msg_seq".
assert second["msg_seq"] == 2
async def test_send_does_not_fallback_on_transport_error(self):
def test_send_does_not_fallback_on_transport_error(self):
channel = self._make_ready_channel()
async def _send_once(coro_factory, max_retries=3):
@@ -100,13 +101,13 @@ class TestQQChannelSend:
},
)
assert await channel.send(msg) is False
assert _run(channel.send(msg)) is False
channel._client.api.post_c2c_message.assert_awaited_once()
sent = channel._client.api.post_c2c_message.await_args.kwargs
assert sent["msg_type"] == 2
assert "content" not in sent
async def test_send_does_not_fallback_when_transport_error_mentions_markdown(self):
def test_send_does_not_fallback_when_transport_error_mentions_markdown(self):
"""A transport-layer error whose message incidentally contains the word
"markdown" must NOT be reclassified as a markdown compatibility failure,
otherwise genuine send failures get silently swallowed as plain-text."""
@@ -132,10 +133,10 @@ class TestQQChannelSend:
},
)
assert await channel.send(msg) is False
assert _run(channel.send(msg)) is False
channel._client.api.post_c2c_message.assert_awaited_once()
async def test_send_falls_back_on_qq_server_error_code(self):
def test_send_falls_back_on_qq_server_error_code(self):
"""QQ server-side markdown errors (e.g. 304014 template not configured)
should trigger plain-text fallback with a fresh msg_seq."""
channel = self._make_ready_channel()
@@ -158,7 +159,7 @@ class TestQQChannelSend:
},
)
assert await channel.send(msg) is True
assert _run(channel.send(msg)) is True
assert channel._client.api.post_c2c_message.await_count == 2
first = channel._client.api.post_c2c_message.await_args_list[0].kwargs
@@ -230,7 +231,7 @@ class TestQQSendWithButtons:
channel._client.api.post_group_message = AsyncMock()
return channel
async def test_c2c_send_attaches_keyboard(self):
def test_c2c_send_attaches_keyboard(self):
channel = self._make_channel()
msg = OutboundMessage(
channel="qq",
@@ -246,7 +247,7 @@ class TestQQSendWithButtons:
],
},
)
assert await channel.send(msg) is True
assert _run(channel.send(msg)) is True
sent = channel._client.api.post_c2c_message.await_args.kwargs
assert sent["msg_type"] == 2
@@ -255,7 +256,7 @@ class TestQQSendWithButtons:
assert rows[0]["buttons"][0]["action"]["data"] == "1"
assert rows[1]["buttons"][0]["action"]["data"] == "2"
async def test_group_send_does_not_attach_keyboard(self):
def test_group_send_does_not_attach_keyboard(self):
"""Group keyboards are out of scope — silently dropped."""
channel = self._make_channel()
msg = OutboundMessage(
@@ -269,11 +270,11 @@ class TestQQSendWithButtons:
"buttons": [{"text": "Approve", "value": "1"}],
},
)
assert await channel.send(msg) is True
assert _run(channel.send(msg)) is True
sent = channel._client.api.post_group_message.await_args.kwargs
assert "keyboard" not in sent
async def test_fallback_appends_button_hint_when_keyboard_present(self):
def test_fallback_appends_button_hint_when_keyboard_present(self):
"""If markdown send fails and we fall back to plain text, the
keyboard is lost — append a textual hint so the user still has
a way to reply (the values still pass `_parse_approval_reply`).
@@ -301,7 +302,7 @@ class TestQQSendWithButtons:
],
},
)
assert await channel.send(msg) is True
assert _run(channel.send(msg)) is True
plain_call = channel._client.api.post_c2c_message.await_args_list[1].kwargs
assert plain_call["msg_type"] == 0
@@ -310,7 +311,7 @@ class TestQQSendWithButtons:
assert "1=Approve" in plain_call["content"]
assert "2=Reject" in plain_call["content"]
async def test_fallback_hint_handles_non_string_button_value(self):
def test_fallback_hint_handles_non_string_button_value(self):
"""Regression: integer/None button values must not crash the
plain-text fallback (the keyboard builder already coerces them)."""
channel = self._make_channel()
@@ -334,7 +335,7 @@ class TestQQSendWithButtons:
],
},
)
assert await channel.send(msg) is True
assert _run(channel.send(msg)) is True
plain_call = channel._client.api.post_c2c_message.await_args_list[1].kwargs
assert "42=OK" in plain_call["content"]
assert "Cancel=Cancel" in plain_call["content"]
@@ -391,9 +392,9 @@ class TestQQInteractionCallback:
)
return interaction
async def test_click_publishes_to_bus_with_button_data(self):
def test_click_publishes_to_bus_with_button_data(self):
channel = self._make_channel()
await channel._on_interaction(self._make_interaction("1"))
_run(channel._on_interaction(self._make_interaction("1")))
channel._bus.publish_inbound.assert_awaited_once()
inbound = channel._bus.publish_inbound.await_args[0][0]
@@ -404,61 +405,63 @@ class TestQQInteractionCallback:
assert inbound.metadata["button_value"] == "1"
assert inbound.metadata["msg_type"] == "c2c"
async def test_click_acks_interaction(self):
def test_click_acks_interaction(self):
channel = self._make_channel()
await channel._on_interaction(self._make_interaction("1"))
_run(channel._on_interaction(self._make_interaction("1")))
channel._client.api.on_interaction_result.assert_awaited_once_with("intr_1", 0)
async def test_click_bypasses_debounce(self):
def test_click_bypasses_debounce(self):
"""Click never hits queue_message (debounce buffer)."""
channel = self._make_channel()
channel.queue_message = AsyncMock()
await channel._on_interaction(self._make_interaction("3"))
_run(channel._on_interaction(self._make_interaction("3")))
channel.queue_message.assert_not_called()
channel._bus.publish_inbound.assert_awaited_once()
async def test_group_interaction_ignored(self):
def test_group_interaction_ignored(self):
"""No user_openid → group/guild click → don't publish."""
channel = self._make_channel()
intr = self._make_interaction(user_openid="")
intr.group_openid = "group_xxx"
await channel._on_interaction(intr)
_run(channel._on_interaction(intr))
channel._bus.publish_inbound.assert_not_called()
# ACK still fires — it runs first, before the group-skip return.
channel._client.api.on_interaction_result.assert_awaited_once_with("intr_1", 0)
async def test_click_dropped_when_middleware_rejects(self):
def test_click_dropped_when_middleware_rejects(self):
channel = self._make_channel()
channel._build_inbound_async = AsyncMock(return_value=None)
await channel._on_interaction(self._make_interaction("1"))
_run(channel._on_interaction(self._make_interaction("1")))
channel._bus.publish_inbound.assert_not_called()
# ACK still fires (we don't want the user staring at a stuck button)
channel._client.api.on_interaction_result.assert_awaited_once()
async def test_empty_button_data_falls_back_to_button_id(self):
def test_empty_button_data_falls_back_to_button_id(self):
channel = self._make_channel()
await channel._on_interaction(
self._make_interaction(button_data="", button_id="btn_3")
_run(
channel._on_interaction(
self._make_interaction(button_data="", button_id="btn_3")
)
)
inbound = channel._bus.publish_inbound.await_args[0][0]
assert inbound.content == "btn_3"
async def test_ack_fires_even_when_handler_throws(self):
def test_ack_fires_even_when_handler_throws(self):
"""ACK must run before downstream processing so the QQ button UI
stays responsive even if middleware/bus crashes."""
channel = self._make_channel()
channel._build_inbound_async = AsyncMock(side_effect=RuntimeError("boom"))
# Should not raise — handler swallows downstream errors.
await channel._on_interaction(self._make_interaction("1"))
_run(channel._on_interaction(self._make_interaction("1")))
channel._client.api.on_interaction_result.assert_awaited_once_with("intr_1", 0)
async def test_button_value_metadata_is_string_coerced(self):
def test_button_value_metadata_is_string_coerced(self):
"""Regression: metadata['button_value'] must be a string (was raw)."""
channel = self._make_channel()
resolved = MagicMock(button_id="btn_0", button_data=42, message_id="msg_orig")
data = MagicMock(type=None, resolved=resolved)
intr = MagicMock(id="intr_1", user_openid="u_x", group_openid=None, data=data)
await channel._on_interaction(intr)
_run(channel._on_interaction(intr))
inbound = channel._bus.publish_inbound.await_args[0][0]
assert inbound.content == "42"
assert inbound.metadata["button_value"] == "42"
+17 -16
View File
@@ -2,6 +2,7 @@
from unittest.mock import AsyncMock, MagicMock
from tests.conftest import run_async as _run
from tests.fakes import FakeGraphGateway, FakeThreadStore
@@ -23,7 +24,7 @@ def _ctx(thread_id="current", workspace_dir="/ws", thread_store=None):
class TestResumeCommand:
async def test_with_arg_resolves_and_calls_ui(self):
def test_with_arg_resolves_and_calls_ui(self):
from EvoScientist.commands.implementation.session import ResumeCommand
ctx, ui = _ctx(
@@ -32,23 +33,23 @@ class TestResumeCommand:
metadata={"workspace_dir": "/restored"},
)
)
await ResumeCommand().execute(ctx, ["target-tid"])
_run(ResumeCommand().execute(ctx, ["target-tid"]))
ui.handle_session_resume.assert_awaited_once_with("target-tid", "/restored")
# ctx mutations
assert ctx.thread_id == "target-tid"
assert ctx.workspace_dir == "/restored"
async def test_no_arg_empty_threads_prints_message(self):
def test_no_arg_empty_threads_prints_message(self):
from EvoScientist.commands.implementation.session import ResumeCommand
ctx, ui = _ctx()
await ResumeCommand().execute(ctx, [])
_run(ResumeCommand().execute(ctx, []))
msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("No sessions to resume" in m for m in msgs)
ui.wait_for_thread_pick.assert_not_called()
ui.handle_session_resume.assert_not_called()
async def test_no_arg_calls_picker(self):
def test_no_arg_calls_picker(self):
from EvoScientist.commands.implementation.session import ResumeCommand
ctx, ui = _ctx()
@@ -59,11 +60,11 @@ class TestResumeCommand:
resolved_thread_id="picked-tid",
)
ctx.graph_gateway = FakeGraphGateway(thread_store=store)
await ResumeCommand().execute(ctx, [])
_run(ResumeCommand().execute(ctx, []))
ui.wait_for_thread_pick.assert_awaited_once()
ui.handle_session_resume.assert_awaited_once()
async def test_picker_cancel_returns(self):
def test_picker_cancel_returns(self):
from EvoScientist.commands.implementation.session import ResumeCommand
ctx, ui = _ctx()
@@ -71,28 +72,28 @@ class TestResumeCommand:
threads = [{"thread_id": "t1", "preview": "", "message_count": 0}]
store = FakeThreadStore(threads=threads)
ctx.graph_gateway = FakeGraphGateway(thread_store=store)
await ResumeCommand().execute(ctx, [])
_run(ResumeCommand().execute(ctx, []))
ui.handle_session_resume.assert_not_called()
async def test_ambiguous_prefix(self):
def test_ambiguous_prefix(self):
from EvoScientist.commands.implementation.session import ResumeCommand
ctx, ui = _ctx(thread_store=FakeThreadStore(matches=["abc-one", "abc-two"]))
await ResumeCommand().execute(ctx, ["abc"])
_run(ResumeCommand().execute(ctx, ["abc"]))
msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Ambiguous" in m for m in msgs)
ui.handle_session_resume.assert_not_called()
async def test_not_found(self):
def test_not_found(self):
from EvoScientist.commands.implementation.session import ResumeCommand
ctx, ui = _ctx()
await ResumeCommand().execute(ctx, ["missing"])
_run(ResumeCommand().execute(ctx, ["missing"]))
msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("not found" in m for m in msgs)
ui.handle_session_resume.assert_not_called()
async def test_prefix_resolves_to_unique_match(self):
def test_prefix_resolves_to_unique_match(self):
from EvoScientist.commands.implementation.session import ResumeCommand
ctx, ui = _ctx(
@@ -101,18 +102,18 @@ class TestResumeCommand:
metadata={"workspace_dir": "/ws1"},
)
)
await ResumeCommand().execute(ctx, ["abc"])
_run(ResumeCommand().execute(ctx, ["abc"]))
ui.handle_session_resume.assert_awaited_once_with("abc-one", "/ws1")
assert ctx.thread_id == "abc-one"
async def test_empty_workspace_metadata_preserves_ctx_workspace(self):
def test_empty_workspace_metadata_preserves_ctx_workspace(self):
from EvoScientist.commands.implementation.session import ResumeCommand
ctx, ui = _ctx(
workspace_dir="/keep",
thread_store=FakeThreadStore(resolved_thread_id="tid", metadata={}),
)
await ResumeCommand().execute(ctx, ["tid"])
_run(ResumeCommand().execute(ctx, ["tid"]))
# ResumeCommand only overwrites ctx.workspace_dir if metadata has one
assert ctx.workspace_dir == "/keep"
# Callback still fires with the metadata value (empty string)
+56 -46
View File
@@ -5,6 +5,8 @@ from unittest.mock import MagicMock
from rich.console import Console
from rich.table import Table
from tests.conftest import run_async as _run
def _make_ui(**kwargs):
"""Build a RichCLICommandUI backed by a MagicMock console."""
@@ -38,9 +40,9 @@ class TestBasicIO:
ui.mount_renderable(table)
console.print.assert_called_once_with(table)
async def test_flush_is_async_noop(self):
def test_flush_is_async_noop(self):
ui, console = _make_ui()
await ui.flush()
_run(ui.flush())
# flush should not print anything
console.print.assert_not_called()
@@ -48,29 +50,33 @@ class TestBasicIO:
class TestWaitForModelPick:
"""CLI model picker fallback: print table + return None."""
async def test_returns_none(self):
def test_returns_none(self):
ui, _ = _make_ui()
entries = [
("claude-sonnet-4-6", "anthropic/claude-sonnet", "anthropic"),
("gpt-4o", "openai/gpt-4o", "openai"),
]
result = await ui.wait_for_model_pick(
entries,
current_model="claude-sonnet-4-6",
current_provider="anthropic",
result = _run(
ui.wait_for_model_pick(
entries,
current_model="claude-sonnet-4-6",
current_provider="anthropic",
)
)
assert result is None
async def test_prints_table_with_current_model_marker(self):
def test_prints_table_with_current_model_marker(self):
ui, console = _make_ui()
entries = [
("claude-sonnet-4-6", "anthropic/claude-sonnet", "anthropic"),
("gpt-4o", "openai/gpt-4o", "openai"),
]
await ui.wait_for_model_pick(
entries,
current_model="claude-sonnet-4-6",
current_provider="anthropic",
_run(
ui.wait_for_model_pick(
entries,
current_model="claude-sonnet-4-6",
current_provider="anthropic",
)
)
# First call renders the Table (Rich renderable), second prints usage.
assert console.print.call_count == 2
@@ -81,23 +87,27 @@ class TestWaitForModelPick:
assert "Usage: /model" in usage_arg
assert "--save" in usage_arg
async def test_no_current_model_no_marker(self):
def test_no_current_model_no_marker(self):
ui, console = _make_ui()
entries = [("claude-sonnet-4-6", "anthropic/claude-sonnet", "anthropic")]
await ui.wait_for_model_pick(
entries,
current_model=None,
current_provider=None,
_run(
ui.wait_for_model_pick(
entries,
current_model=None,
current_provider=None,
)
)
# Just asserts the coroutine runs without marker-branch issues.
assert console.print.call_count == 2
async def test_empty_entries_still_prints_header_and_usage(self):
def test_empty_entries_still_prints_header_and_usage(self):
ui, console = _make_ui()
result = await ui.wait_for_model_pick(
[],
current_model=None,
current_provider=None,
result = _run(
ui.wait_for_model_pick(
[],
current_model=None,
current_provider=None,
)
)
assert result is None
# Header table + usage hint should still be printed even with
@@ -203,7 +213,7 @@ class TestWaitForThreadPick:
},
]
async def test_returns_selected_thread_id(self, monkeypatch):
def test_returns_selected_thread_id(self, monkeypatch):
import EvoScientist.cli.rich_command_ui as mod
ui, _ = _make_ui()
@@ -216,7 +226,7 @@ class TestWaitForThreadPick:
return prompt
monkeypatch.setattr("questionary.select", fake_select)
result = await ui.wait_for_thread_pick(self._threads(), "abc123", "pick:")
result = _run(ui.wait_for_thread_pick(self._threads(), "abc123", "pick:"))
assert result == "abc123"
assert called["title"] == "pick:"
# _build_items prepends a workspace header — choices has headers +
@@ -225,14 +235,14 @@ class TestWaitForThreadPick:
# Table import removed; this test no longer depends on console output.
assert mod.RichCLICommandUI is not None # sanity
async def test_cancel_returns_none(self, monkeypatch):
def test_cancel_returns_none(self, monkeypatch):
ui, _ = _make_ui()
prompt = self._fake_prompt(None)
monkeypatch.setattr("questionary.select", lambda *a, **k: prompt)
result = await ui.wait_for_thread_pick(self._threads(), "abc123", "pick:")
result = _run(ui.wait_for_thread_pick(self._threads(), "abc123", "pick:"))
assert result is None
async def test_current_thread_marker_in_label(self, monkeypatch):
def test_current_thread_marker_in_label(self, monkeypatch):
ui, _ = _make_ui()
prompt = self._fake_prompt(None)
captured_choices: list = []
@@ -242,7 +252,7 @@ class TestWaitForThreadPick:
return prompt
monkeypatch.setattr("questionary.select", fake_select)
await ui.wait_for_thread_pick(self._threads(), "abc123", "pick:")
_run(ui.wait_for_thread_pick(self._threads(), "abc123", "pick:"))
# At least one Choice title contains "abc123 *" (current marker)
choice_titles = [getattr(c, "title", "") for c in captured_choices]
assert any("abc123 *" in t for t in choice_titles)
@@ -272,38 +282,38 @@ class TestCompactIndicator:
class TestPhaseBMigrated:
"""Session lifecycle callbacks (start/resume) filled in Phase B."""
async def test_start_new_session_fires_callback(self):
def test_start_new_session_fires_callback(self):
from unittest.mock import AsyncMock
cb = AsyncMock()
ui, _ = _make_ui(on_start_new_session=cb)
await ui.start_new_session()
_run(ui.start_new_session())
cb.assert_awaited_once()
async def test_start_new_session_without_callback_is_noop(self):
def test_start_new_session_without_callback_is_noop(self):
ui, console = _make_ui()
await ui.start_new_session()
_run(ui.start_new_session())
console.print.assert_not_called()
async def test_handle_session_resume_awaits_callback(self):
def test_handle_session_resume_awaits_callback(self):
from unittest.mock import AsyncMock
cb = AsyncMock()
ui, _ = _make_ui(on_handle_session_resume=cb)
await ui.handle_session_resume("tid-x", "/workspace")
_run(ui.handle_session_resume("tid-x", "/workspace"))
cb.assert_awaited_once_with("tid-x", "/workspace")
async def test_handle_session_resume_without_callback_is_noop(self):
def test_handle_session_resume_without_callback_is_noop(self):
ui, _ = _make_ui()
# Should not raise
await ui.handle_session_resume("tid-x")
_run(ui.handle_session_resume("tid-x"))
async def test_handle_session_resume_workspace_defaults_none(self):
def test_handle_session_resume_workspace_defaults_none(self):
from unittest.mock import AsyncMock
cb = AsyncMock()
ui, _ = _make_ui(on_handle_session_resume=cb)
await ui.handle_session_resume("tid-x")
_run(ui.handle_session_resume("tid-x"))
cb.assert_awaited_once_with("tid-x", None)
@@ -311,7 +321,7 @@ class TestPhaseCMigrated:
"""Skill/MCP browse pickers delegate to questionary helpers via
``asyncio.to_thread`` since questionary blocks the event loop."""
async def test_skill_browse_delegates_to_picker(self, monkeypatch):
def test_skill_browse_delegates_to_picker(self, monkeypatch):
from unittest.mock import MagicMock
picker = MagicMock(return_value=["skill-a", "skill-b"])
@@ -320,11 +330,11 @@ class TestPhaseCMigrated:
picker,
)
ui, _ = _make_ui()
result = await ui.wait_for_skill_browse([{"name": "a"}], {"installed"}, "core")
result = _run(ui.wait_for_skill_browse([{"name": "a"}], {"installed"}, "core"))
assert result == ["skill-a", "skill-b"]
picker.assert_called_once_with([{"name": "a"}], {"installed"}, "core")
async def test_skill_browse_cancel_returns_none(self, monkeypatch):
def test_skill_browse_cancel_returns_none(self, monkeypatch):
from unittest.mock import MagicMock
monkeypatch.setattr(
@@ -332,10 +342,10 @@ class TestPhaseCMigrated:
MagicMock(return_value=None),
)
ui, _ = _make_ui()
result = await ui.wait_for_skill_browse([], set(), "")
result = _run(ui.wait_for_skill_browse([], set(), ""))
assert result is None
async def test_mcp_browse_delegates_to_picker(self, monkeypatch):
def test_mcp_browse_delegates_to_picker(self, monkeypatch):
from unittest.mock import MagicMock
sentinel_entries = [MagicMock(name="entry1"), MagicMock(name="entry2")]
@@ -345,11 +355,11 @@ class TestPhaseCMigrated:
picker,
)
ui, _ = _make_ui()
result = await ui.wait_for_mcp_browse([MagicMock()], {"configured"}, "")
result = _run(ui.wait_for_mcp_browse([MagicMock()], {"configured"}, ""))
assert result is sentinel_entries
picker.assert_called_once()
async def test_mcp_browse_cancel_returns_none(self, monkeypatch):
def test_mcp_browse_cancel_returns_none(self, monkeypatch):
from unittest.mock import MagicMock
monkeypatch.setattr(
@@ -357,5 +367,5 @@ class TestPhaseCMigrated:
MagicMock(return_value=None),
)
ui, _ = _make_ui()
result = await ui.wait_for_mcp_browse([], set(), "")
result = _run(ui.wait_for_mcp_browse([], set(), ""))
assert result is None
-117
View File
@@ -1,117 +0,0 @@
from __future__ import annotations
import ast
from pathlib import Path
import pytest
from EvoScientist.runtime_integrations import (
RuntimeIntegrationUnavailable,
configure_runtime_integrations,
get_app_connection,
get_image_backend,
get_session_connection,
get_session_dsn,
handle_knowledge_file,
record_service_usage,
reset_runtime_integrations,
resolve_runtime_model,
)
@pytest.fixture(autouse=True)
def reset_integrations():
reset_runtime_integrations()
yield
reset_runtime_integrations()
def test_core_package_does_not_import_gateway():
package_root = Path(__file__).resolve().parents[1] / "EvoScientist"
violations = []
for source_file in package_root.rglob("*.py"):
tree = ast.parse(
source_file.read_text(encoding="utf-8"), filename=str(source_file)
)
for node in ast.walk(tree):
if isinstance(node, ast.Import):
names = [alias.name for alias in node.names]
elif isinstance(node, ast.ImportFrom):
if node.level:
continue
names = [node.module or ""]
else:
continue
if any(name == "gateway" or name.startswith("gateway.") for name in names):
violations.append(
f"{source_file.relative_to(package_root)}:{node.lineno}"
)
assert violations == []
@pytest.mark.anyio
async def test_optional_integrations_are_safe_without_web_runtime(tmp_path):
assert get_session_dsn() is None
await handle_knowledge_file(tmp_path / "result.md")
await record_service_usage("search", "query")
with pytest.raises(RuntimeIntegrationUnavailable):
await get_app_connection()
with pytest.raises(RuntimeIntegrationUnavailable):
await get_session_connection()
with pytest.raises(RuntimeIntegrationUnavailable):
get_image_backend()
@pytest.mark.anyio
async def test_host_can_register_runtime_integrations(tmp_path):
app_connection = object()
session_connection = object()
knowledge_paths = []
usage = []
image_backend = object()
async def provide_app_connection():
return app_connection
async def provide_session_connection():
return session_connection
async def handle_file(path):
knowledge_paths.append(path)
async def record_usage(service, action):
usage.append((service, action))
configure_runtime_integrations(
app_connection_provider=provide_app_connection,
session_connection_provider=provide_session_connection,
session_dsn_provider=lambda: "postgresql://example/session",
knowledge_file_handler=handle_file,
usage_recorder=record_usage,
image_backend_factory=lambda: image_backend,
)
path = tmp_path / "result.md"
await handle_knowledge_file(path)
await record_service_usage("mineru", "parse")
assert await get_app_connection() is app_connection
assert await get_session_connection() is session_connection
assert get_session_dsn() == "postgresql://example/session"
assert get_image_backend() is image_backend
assert knowledge_paths == [path]
assert usage == [("mineru", "parse")]
def test_host_can_register_model_resolver():
resolved = object()
calls = []
def resolve_model(model, provider):
calls.append((model, provider))
return resolved
configure_runtime_integrations(model_resolver=resolve_model)
assert resolve_runtime_model("model-a", "provider-a") is resolved
assert calls == [("model-a", "provider-a")]
+28 -24
View File
@@ -2,6 +2,8 @@
from unittest.mock import MagicMock, patch
from tests.conftest import run_async as _run
def _ctx():
from EvoScientist.commands.base import CommandContext
@@ -10,17 +12,17 @@ def _ctx():
return CommandContext(agent=None, thread_id="tid", ui=ui), ui
async def test_list_when_backend_down():
def test_list_when_backend_down():
from EvoScientist.commands.implementation.schedule import ScheduleCommand
ctx, ui = _ctx()
with patch("EvoScientist.cron.schedule.is_available", return_value=False):
await ScheduleCommand().execute(ctx, ["list"])
_run(ScheduleCommand().execute(ctx, ["list"]))
msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("unavailable" in m.lower() for m in msgs)
async def test_add_parses_five_field_cron_and_prompt():
def test_add_parses_five_field_cron_and_prompt():
from EvoScientist.commands.implementation.schedule import ScheduleCommand
ctx, _ui = _ctx()
@@ -31,15 +33,17 @@ async def test_add_parses_five_field_cron_and_prompt():
return_value={"cron_id": "c-9"},
) as mk,
):
await ScheduleCommand().execute(
ctx, ["add", "*/10", "*", "*", "*", "*", "search", "uk", "weather"]
_run(
ScheduleCommand().execute(
ctx, ["add", "*/10", "*", "*", "*", "*", "search", "uk", "weather"]
)
)
kw = mk.call_args.kwargs
assert kw["schedule"] == "*/10 * * * *"
assert kw["prompt"] == "search uk weather"
async def test_list_renders_table():
def test_list_renders_table():
from EvoScientist.commands.implementation.schedule import ScheduleCommand
ctx, ui = _ctx()
@@ -56,11 +60,11 @@ async def test_list_renders_table():
patch("EvoScientist.cron.schedule.is_available", return_value=True),
patch("EvoScientist.cron.schedule.list_schedules", return_value=rows),
):
await ScheduleCommand().execute(ctx, ["list"])
_run(ScheduleCommand().execute(ctx, ["list"]))
ui.mount_renderable.assert_called_once()
async def test_add_parses_quoted_cron_and_prompt():
def test_add_parses_quoted_cron_and_prompt():
from EvoScientist.commands.implementation.schedule import ScheduleCommand
ctx, _ui = _ctx()
@@ -71,15 +75,15 @@ async def test_add_parses_quoted_cron_and_prompt():
return_value={"cron_id": "c-9"},
) as mk,
):
await ScheduleCommand().execute(
ctx, ["add", "*/10 * * * *", "search uk weather"]
_run(
ScheduleCommand().execute(ctx, ["add", "*/10 * * * *", "search uk weather"])
)
kw = mk.call_args.kwargs
assert kw["schedule"] == "*/10 * * * *"
assert kw["prompt"] == "search uk weather"
async def test_run_with_matching_prefix_fires_matched_prompt():
def test_run_with_matching_prefix_fires_matched_prompt():
from EvoScientist.commands.implementation.schedule import ScheduleCommand
ctx, _ui = _ctx()
@@ -92,11 +96,11 @@ async def test_run_with_matching_prefix_fires_matched_prompt():
return_value={"run_id": "r-1"},
) as rn,
):
await ScheduleCommand().execute(ctx, ["run", "c-123"])
_run(ScheduleCommand().execute(ctx, ["run", "c-123"]))
rn.assert_called_once_with("do the thing")
async def test_run_with_no_match_reports():
def test_run_with_no_match_reports():
from EvoScientist.commands.implementation.schedule import ScheduleCommand
ctx, ui = _ctx()
@@ -105,13 +109,13 @@ async def test_run_with_no_match_reports():
patch("EvoScientist.cron.schedule.list_schedules", return_value=[]),
patch("EvoScientist.cron.schedule.run_now") as rn,
):
await ScheduleCommand().execute(ctx, ["run", "nope"])
_run(ScheduleCommand().execute(ctx, ["run", "nope"]))
rn.assert_not_called()
msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("No schedule matching" in m for m in msgs)
async def test_pause_resume_set_enabled_with_resolved_id():
def test_pause_resume_set_enabled_with_resolved_id():
from EvoScientist.commands.implementation.schedule import ScheduleCommand
rows = [{"cron_id": "c-abcdef", "metadata": {"name": "t"}}]
@@ -122,7 +126,7 @@ async def test_pause_resume_set_enabled_with_resolved_id():
patch("EvoScientist.cron.schedule.list_schedules", return_value=rows),
patch("EvoScientist.cron.schedule.set_enabled") as se,
):
await ScheduleCommand().execute(ctx, [sub, "c-abc"])
_run(ScheduleCommand().execute(ctx, [sub, "c-abc"]))
se.assert_called_once_with("c-abcdef", expected)
@@ -131,7 +135,7 @@ async def test_pause_resume_set_enabled_with_resolved_id():
# ---------------------------------------------------------------------------
async def test_list_error_shows_red_message_no_exception():
def test_list_error_shows_red_message_no_exception():
"""B1: list_schedules raising after is_available() shows a red error, not a traceback."""
from EvoScientist.commands.implementation.schedule import ScheduleCommand
@@ -143,7 +147,7 @@ async def test_list_error_shows_red_message_no_exception():
side_effect=RuntimeError("backend gone"),
),
):
await ScheduleCommand().execute(ctx, ["list"])
_run(ScheduleCommand().execute(ctx, ["list"]))
msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Error:" in m for m in msgs)
# Verify no exception escaped (test would have raised above otherwise)
@@ -154,7 +158,7 @@ async def test_list_error_shows_red_message_no_exception():
# ---------------------------------------------------------------------------
async def test_remove_ambiguous_prefix_aborts_without_deleting():
def test_remove_ambiguous_prefix_aborts_without_deleting():
"""B2: two crons sharing a prefix → ambiguity message, delete NOT called."""
from EvoScientist.commands.implementation.schedule import ScheduleCommand
@@ -168,7 +172,7 @@ async def test_remove_ambiguous_prefix_aborts_without_deleting():
patch("EvoScientist.cron.schedule.list_schedules", return_value=rows),
patch("EvoScientist.cron.schedule.delete_schedule") as mk,
):
await ScheduleCommand().execute(ctx, ["remove", "abc"])
_run(ScheduleCommand().execute(ctx, ["remove", "abc"]))
mk.assert_not_called()
msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Multiple" in m for m in msgs)
@@ -179,7 +183,7 @@ async def test_remove_ambiguous_prefix_aborts_without_deleting():
# ---------------------------------------------------------------------------
async def test_remove_backend_error_shows_red_error_not_no_match():
def test_remove_backend_error_shows_red_error_not_no_match():
"""FIX 1: list_schedules() crashing in _resolve → red 'Error:' message, not 'No schedule matching'."""
from EvoScientist.commands.implementation.schedule import ScheduleCommand
@@ -192,7 +196,7 @@ async def test_remove_backend_error_shows_red_error_not_no_match():
),
patch("EvoScientist.cron.schedule.delete_schedule") as mk,
):
await ScheduleCommand().execute(ctx, ["remove", "abc"])
_run(ScheduleCommand().execute(ctx, ["remove", "abc"]))
mk.assert_not_called()
msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Error:" in m for m in msgs), f"Expected red Error: message, got: {msgs}"
@@ -205,7 +209,7 @@ async def test_remove_backend_error_shows_red_error_not_no_match():
)
async def test_add_name_sanitized_from_nasty_prompt():
def test_add_name_sanitized_from_nasty_prompt():
"""B3: prompt with newline / slashes / special chars → clean kebab-case name."""
import re
@@ -221,7 +225,7 @@ async def test_add_name_sanitized_from_nasty_prompt():
return_value={"cron_id": "c-x"},
) as mk,
):
await ScheduleCommand().execute(ctx, ["add", "*/5 * * * *", nasty_prompt])
_run(ScheduleCommand().execute(ctx, ["add", "*/5 * * * *", nasty_prompt]))
name = mk.call_args.kwargs["name"]
# Must be non-empty, no spaces, no newlines, no slashes
assert name
+36 -35
View File
@@ -27,6 +27,7 @@ from EvoScientist.cli.commands import (
from EvoScientist.commands.base import ChannelRuntime
from EvoScientist.config import EvoScientistConfig
from EvoScientist.gateway import RuntimeGateways, ThreadStore
from tests.conftest import run_async as _run
from tests.fakes import FakeGraphGateway, FakeThreadStore
@@ -70,7 +71,7 @@ def _runtime_state(
)
async def test_hook_updates_runtime_state_on_agent_swap():
def test_hook_updates_runtime_state_on_agent_swap():
"""``/model`` mutates ``ctx.agent`` to a new handle — the hook must
push that handle into the shared runtime state so the outer poll loop sees
it on the next message."""
@@ -85,12 +86,12 @@ async def test_hook_updates_runtime_state_on_agent_swap():
cmd = MagicMock()
cmd.name = "/model"
await hook(ctx, original_agent, cmd)
_run(hook(ctx, original_agent, cmd))
assert state.agent is new_agent
async def test_hook_syncs_channel_runtime():
def test_hook_syncs_channel_runtime():
"""Other readers (the bus) look at ``ChannelRuntime.agent``; the
hook keeps the runtime in sync with the runtime state update."""
original_agent = _agent("original-agent")
@@ -108,13 +109,13 @@ async def test_hook_syncs_channel_runtime():
cmd = MagicMock()
cmd.name = "/model"
await hook(ctx, original_agent, cmd)
_run(hook(ctx, original_agent, cmd))
assert runtime.agent is new_agent
assert runtime.thread_id == "t"
async def test_hook_noop_when_agent_unchanged():
def test_hook_noop_when_agent_unchanged():
"""Commands like ``/evoskills`` don't touch ``ctx.agent`` — the
runtime state must stay put."""
original_agent = _agent("original-agent")
@@ -127,12 +128,12 @@ async def test_hook_noop_when_agent_unchanged():
cmd = MagicMock()
cmd.name = "/evoskills"
await hook(ctx, original_agent, cmd)
_run(hook(ctx, original_agent, cmd))
assert state.agent is original_agent
async def test_hook_noop_when_ctx_agent_is_none():
def test_hook_noop_when_ctx_agent_is_none():
"""Guard against commands that reset ``ctx.agent`` to ``None`` —
we never want to write ``None`` into runtime state."""
original_agent = _agent("original-agent")
@@ -145,12 +146,12 @@ async def test_hook_noop_when_ctx_agent_is_none():
cmd = MagicMock()
cmd.name = "/whatever"
await hook(ctx, original_agent, cmd)
_run(hook(ctx, original_agent, cmd))
assert state.agent is original_agent
async def test_hook_updates_thread_id_on_resume():
def test_hook_updates_thread_id_on_resume():
"""``/resume`` mutates ``ctx.thread_id`` — the hook must push the
new id into runtime state so the outer poll loop runs subsequent
messages on the resumed thread."""
@@ -165,12 +166,12 @@ async def test_hook_updates_thread_id_on_resume():
cmd = MagicMock()
cmd.name = "/resume"
await hook(ctx, agent, cmd)
_run(hook(ctx, agent, cmd))
assert state.thread_id == "new-tid"
async def test_hook_updates_workspace_dir_on_resume():
def test_hook_updates_workspace_dir_on_resume():
"""`/resume` can restore a different workspace; serve must reload for it."""
cfg = _config()
old_agent = _agent("old-agent")
@@ -200,7 +201,7 @@ async def test_hook_updates_workspace_dir_on_resume():
return_value=reloaded_agent,
) as load_agent,
):
await hook(ctx, old_agent, cmd)
_run(hook(ctx, old_agent, cmd))
sync_server.assert_awaited_once_with(cfg, workspace_dir="/restored-ws")
load_agent.assert_called_once_with(workspace_dir="/restored-ws", config=cfg)
@@ -208,7 +209,7 @@ async def test_hook_updates_workspace_dir_on_resume():
assert state.agent is reloaded_agent
async def test_hook_syncs_channel_runtime_thread_id():
def test_hook_syncs_channel_runtime_thread_id():
"""The bus reads ``ChannelRuntime.thread_id``; hook must sync it
alongside the runtime state update."""
agent = _agent("a")
@@ -223,12 +224,12 @@ async def test_hook_syncs_channel_runtime_thread_id():
cmd = MagicMock()
cmd.name = "/resume"
await hook(ctx, agent, cmd)
_run(hook(ctx, agent, cmd))
assert runtime.thread_id == "new-tid"
async def test_hook_noop_when_thread_id_unchanged():
def test_hook_noop_when_thread_id_unchanged():
"""Most commands don't touch thread_id — runtime state stays put."""
agent = _agent("a")
state = _runtime_state(agent=agent, thread_id="same-tid")
@@ -240,12 +241,12 @@ async def test_hook_noop_when_thread_id_unchanged():
cmd = MagicMock()
cmd.name = "/evoskills"
await hook(ctx, agent, cmd)
_run(hook(ctx, agent, cmd))
assert state.thread_id == "same-tid"
async def test_hook_skips_resume_warning_when_thread_unchanged():
def test_hook_skips_resume_warning_when_thread_unchanged():
"""Bare ``/resume`` with no argument prints usage but leaves
``ctx.thread_id`` unchanged — the in-memory-state warning must NOT
fire because no resume actually happened."""
@@ -260,13 +261,13 @@ async def test_hook_skips_resume_warning_when_thread_unchanged():
cmd = MagicMock()
cmd.name = "/resume"
await hook(ctx, agent, cmd)
_run(hook(ctx, agent, cmd))
ctx.ui.append_system.assert_not_called()
ctx.ui.flush.assert_not_called()
async def test_hook_emits_resume_warning_when_thread_changed():
def test_hook_emits_resume_warning_when_thread_changed():
"""``/resume <tid>`` that actually changes thread_id must surface
the in-memory-state warning via ``ctx.ui``."""
agent = _agent("a")
@@ -282,7 +283,7 @@ async def test_hook_emits_resume_warning_when_thread_changed():
cmd = MagicMock()
cmd.name = "/resume"
await hook(ctx, agent, cmd)
_run(hook(ctx, agent, cmd))
ctx.ui.append_system.assert_called_once()
warn_text, warn_kwargs = (
@@ -295,7 +296,7 @@ async def test_hook_emits_resume_warning_when_thread_changed():
ctx.ui.flush.assert_awaited_once()
async def test_start_new_session_cb_rotates_thread_id():
def test_start_new_session_cb_rotates_thread_id():
"""``/new`` via channel calls this callback — must generate a new
thread id, push into runtime state, and sync the channel runtime."""
agent = _agent("a")
@@ -310,13 +311,13 @@ async def test_start_new_session_cb_rotates_thread_id():
state,
runtime,
)
await cb()
_run(cb())
assert state.thread_id == "freshly-generated-tid"
assert runtime.thread_id == "freshly-generated-tid"
async def test_start_new_session_cb_leaves_agent_alone():
def test_start_new_session_cb_leaves_agent_alone():
"""``/new`` rotates thread only — agent handle must stay put
(serve's agent is a single pre-loaded instance, not per-thread)."""
agent = _agent("a")
@@ -327,12 +328,12 @@ async def test_start_new_session_cb_leaves_agent_alone():
)
cb = _make_serve_start_new_session_cb(state)
await cb()
_run(cb())
assert state.agent is agent
async def test_serve_resume_callback_syncs_reloads_and_adopts_workspace():
def test_serve_resume_callback_syncs_reloads_and_adopts_workspace():
cfg = _config()
old_agent = _agent("old-agent")
reloaded_agent = _agent("reloaded-agent")
@@ -363,7 +364,7 @@ async def test_serve_resume_callback_syncs_reloads_and_adopts_workspace():
side_effect=_load_agent,
) as load_agent,
):
await cb("new-tid", "/new-ws")
_run(cb("new-tid", "/new-ws"))
sync_server.assert_awaited_once_with(cfg, workspace_dir="/new-ws")
load_agent.assert_called_once_with(workspace_dir="/new-ws", config=cfg)
@@ -375,7 +376,7 @@ async def test_serve_resume_callback_syncs_reloads_and_adopts_workspace():
assert runtime.agent is reloaded_agent
async def test_hook_emits_resume_warning_after_resume_callback_adopts_thread():
def test_hook_emits_resume_warning_after_resume_callback_adopts_thread():
cfg = _config()
old_agent = _agent("old-agent")
reloaded_agent = _agent("reloaded-agent")
@@ -398,7 +399,7 @@ async def test_hook_emits_resume_warning_after_resume_callback_adopts_thread():
return_value=reloaded_agent,
),
):
await cb("abc12345-resumed-tid", "/new-ws")
_run(cb("abc12345-resumed-tid", "/new-ws"))
hook = _make_serve_cmd_completed_hook(state, runtime, config=cfg)
ctx = MagicMock()
@@ -409,14 +410,14 @@ async def test_hook_emits_resume_warning_after_resume_callback_adopts_thread():
cmd = MagicMock()
cmd.name = "/resume"
await hook(ctx, reloaded_agent, cmd)
_run(hook(ctx, reloaded_agent, cmd))
ctx.ui.append_system.assert_called_once()
assert "in-memory state" in ctx.ui.append_system.call_args.args[0]
ctx.ui.flush.assert_awaited_once()
async def test_serve_resume_callback_preserves_state_when_sync_fails():
def test_serve_resume_callback_preserves_state_when_sync_fails():
cfg = _config()
old_agent = _agent("old-agent")
loaded_but_not_adopted = _agent("loaded-but-not-adopted")
@@ -441,7 +442,7 @@ async def test_serve_resume_callback_preserves_state_when_sync_fails():
patch("EvoScientist.cli.commands.set_active_workspace") as set_active,
pytest.raises(RuntimeError, match="workspace conflict"),
):
await cb("new-tid", "/new-ws")
_run(cb("new-tid", "/new-ws"))
load_agent.assert_called_once_with(workspace_dir="/new-ws", config=cfg)
set_active.assert_called_once_with("/old-ws")
@@ -454,7 +455,7 @@ async def test_serve_resume_callback_preserves_state_when_sync_fails():
assert runtime.thread_id == "old-tid"
async def test_serve_resume_callback_load_failure_does_not_sync_or_adopt():
def test_serve_resume_callback_load_failure_does_not_sync_or_adopt():
cfg = _config()
old_agent = _agent("old-agent")
state = _runtime_state(
@@ -478,7 +479,7 @@ async def test_serve_resume_callback_load_failure_does_not_sync_or_adopt():
) as sync_server,
pytest.raises(RuntimeError, match="load failed"),
):
await cb("new-tid", "/new-ws")
_run(cb("new-tid", "/new-ws"))
load_agent.assert_called_once_with(workspace_dir="/new-ws", config=cfg)
set_active.assert_called_once_with("/old-ws")
@@ -492,7 +493,7 @@ async def test_serve_resume_callback_load_failure_does_not_sync_or_adopt():
assert runtime.thread_id == "old-tid"
async def test_hook_handles_both_agent_and_thread_swap():
def test_hook_handles_both_agent_and_thread_swap():
"""Edge case: a command that changes both (hypothetical). Both
updates must land in runtime state."""
old_agent = _agent("old-agent")
@@ -505,7 +506,7 @@ async def test_hook_handles_both_agent_and_thread_swap():
ctx.thread_id = "new-tid"
cmd = MagicMock()
await hook(ctx, old_agent, cmd)
_run(hook(ctx, old_agent, cmd))
assert state.agent is new_agent
assert state.thread_id == "new-tid"
+220 -222
View File
File diff suppressed because it is too large Load Diff
+9 -8
View File
@@ -4,6 +4,7 @@ import pytest
from EvoScientist.channels.base import ChannelError
from EvoScientist.channels.slack.channel import SlackChannel, SlackConfig
from tests.conftest import run_async as _run
class TestSlackConfig:
@@ -37,24 +38,24 @@ class TestSlackChannel:
assert channel.config is config
assert channel._running is False
async def test_start_raises_without_bot_token(self):
def test_start_raises_without_bot_token(self):
config = SlackConfig(bot_token="", app_token="xapp-test")
channel = SlackChannel(config)
with pytest.raises(ChannelError, match="bot token"):
await channel.start()
_run(channel.start())
async def test_start_raises_without_app_token(self):
def test_start_raises_without_app_token(self):
config = SlackConfig(bot_token="xoxb-test", app_token="")
channel = SlackChannel(config)
with pytest.raises(ChannelError, match="app token"):
await channel.start()
_run(channel.start())
async def test_stop_when_not_running(self):
def test_stop_when_not_running(self):
config = SlackConfig(bot_token="xoxb-test", app_token="xapp-test")
channel = SlackChannel(config)
await channel.stop()
_run(channel.stop())
async def test_send_returns_false_without_client(self):
def test_send_returns_false_without_client(self):
from EvoScientist.channels.base import OutboundMessage
config = SlackConfig(bot_token="xoxb-test", app_token="xapp-test")
@@ -65,7 +66,7 @@ class TestSlackChannel:
content="hello",
metadata={"chat_id": "C123"},
)
result = await channel.send(msg)
result = _run(channel.send(msg))
assert result is False
+12 -7
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
from datetime import datetime, timedelta
from typing import ClassVar
@@ -242,7 +243,7 @@ def test_build_status_text_uses_rich_styles():
assert text.spans
async def test_build_session_status_snapshot_uses_fallback_window(monkeypatch):
def test_build_session_status_snapshot_uses_fallback_window(monkeypatch):
class _FakeModel:
model_name: ClassVar[str] = "provider/demo-model"
profile: ClassVar[dict[str, object]] = {}
@@ -261,12 +262,16 @@ async def test_build_session_status_snapshot_uses_fallback_window(monkeypatch):
_fake_count,
)
snapshot = await build_session_status_snapshot(
"thread-1",
pending_user_text="pending",
graph_gateway=FakeGraphGateway(
thread_store=FakeThreadStore(messages=[HumanMessage(content="existing")])
),
snapshot = asyncio.run(
build_session_status_snapshot(
"thread-1",
pending_user_text="pending",
graph_gateway=FakeGraphGateway(
thread_store=FakeThreadStore(
messages=[HumanMessage(content="existing")]
)
),
)
)
assert snapshot.model_full == "provider/demo-model"
+109 -103
View File
@@ -20,6 +20,7 @@ from EvoScientist.stream.tool_results import (
_extract_command_tool_content,
_extract_tool_content,
)
from tests.conftest import run_async
from tests.stream_v3_fakes import (
ErroringV3Agent,
FakeSubagent,
@@ -143,10 +144,10 @@ class TestExtractToolContent:
class TestV3ProtocolStreaming:
"""Test stream_agent_events against v3 protocol events."""
async def test_message_delta_emits_text(self):
def test_message_delta_emits_text(self):
"""v3 content-block text deltas are processed."""
agent = FakeV3Agent([message_delta("hello world")])
events = await collect_events(agent)
events = collect_events(agent)
text_events = [e for e in events if e.get("type") == "text"]
assert len(text_events) == 1
assert text_events[0]["content"] == "hello world"
@@ -155,7 +156,7 @@ class TestV3ProtocolStreaming:
assert "stream_mode" not in kwargs
assert "subgraphs" not in kwargs
async def test_streamed_non_selector_json_is_replayed(self):
def test_streamed_non_selector_json_is_replayed(self):
"""Normal JSON answers are not swallowed by selector JSON buffering."""
agent = FakeV3Agent(
[
@@ -164,13 +165,13 @@ class TestV3ProtocolStreaming:
message_delta(": 1}"),
]
)
events = await collect_events(agent)
events = collect_events(agent)
text_events = [e for e in events if e.get("type") == "text"]
assert "".join(e["content"] for e in text_events) == '{"answer": 1}'
assert events[-1]["type"] == "done"
assert events[-1]["response"] == '{"answer": 1}'
async def test_incomplete_non_selector_json_flushes_on_message_finish(self):
def test_incomplete_non_selector_json_flushes_on_message_finish(self):
"""Buffered non-selector text is not lost if the message ends mid-object."""
agent = FakeV3Agent(
[
@@ -179,42 +180,40 @@ class TestV3ProtocolStreaming:
message_finish(),
]
)
events = await collect_events(agent)
events = collect_events(agent)
text_events = [e for e in events if e.get("type") == "text"]
assert "".join(e["content"] for e in text_events) == '{"answer":'
assert events[-1]["response"] == '{"answer":'
async def test_json_answer_with_tools_key_is_replayed_without_selector_context(
self,
):
def test_json_answer_with_tools_key_is_replayed_without_selector_context(self):
"""Normal answers may legitimately contain a top-level tools key."""
agent = FakeV3Agent(
[
message_delta('{"tools":["hammer"],"answer":"use safely"}'),
]
)
events = await collect_events(agent)
events = collect_events(agent)
text_events = [e for e in events if e.get("type") == "text"]
assert len(text_events) == 1
assert text_events[0]["content"] == '{"tools":["hammer"],"answer":"use safely"}'
assert events[-1]["response"] == '{"tools":["hammer"],"answer":"use safely"}'
async def test_text_delta_strips_legacy_thinking_tags(self):
def test_text_delta_strips_legacy_thinking_tags(self):
"""Legacy <thinking> tags are still removed on the v3 text path."""
agent = FakeV3Agent(
[message_delta("<thinking>some reasoning</thinking>The answer is 42.")]
)
events = await collect_events(agent)
events = collect_events(agent)
text_events = [e for e in events if e.get("type") == "text"]
assert len(text_events) == 1
assert text_events[0]["content"] == "The answer is 42."
async def test_text_delta_with_only_legacy_thinking_tags_is_skipped(self):
def test_text_delta_with_only_legacy_thinking_tags_is_skipped(self):
agent = FakeV3Agent([message_delta("<thinking>just reasoning</thinking>")])
events = await collect_events(agent)
events = collect_events(agent)
assert [e for e in events if e.get("type") == "text"] == []
async def test_updates_event_without_summary_is_skipped(self):
def test_updates_event_without_summary_is_skipped(self):
"""Non-summary updates are skipped without error."""
agent = FakeV3Agent(
[
@@ -222,14 +221,12 @@ class TestV3ProtocolStreaming:
message_delta("should appear"),
]
)
events = await collect_events(agent)
events = collect_events(agent)
text_events = [e for e in events if e.get("type") == "text"]
assert len(text_events) == 1
assert text_events[0]["content"] == "should appear"
async def test_user_message_clears_completed_memory_activity_counts(
self, monkeypatch
):
def test_user_message_clears_completed_memory_activity_counts(self, monkeypatch):
calls = []
monkeypatch.setattr(
"EvoScientist.stream.events.clear_completed_memory_activity_counts",
@@ -237,13 +234,11 @@ class TestV3ProtocolStreaming:
)
agent = FakeV3Agent([])
await collect_events(agent, message="new user turn")
collect_events(agent, message="new user turn")
assert calls == [True]
async def test_command_message_clears_completed_memory_activity_counts(
self, monkeypatch
):
def test_command_message_clears_completed_memory_activity_counts(self, monkeypatch):
calls = []
monkeypatch.setattr(
"EvoScientist.stream.events.clear_completed_memory_activity_counts",
@@ -252,12 +247,12 @@ class TestV3ProtocolStreaming:
agent = FakeV3Agent([])
resume_command = Command(resume={"decisions": [{"type": "approve"}]})
await collect_events(agent, message=resume_command)
collect_events(agent, message=resume_command)
assert calls == [True]
assert agent.astream_events.call_args.args[0] is resume_command
async def test_summarization_filtered(self):
def test_summarization_filtered(self):
"""v3 messages with lc_source=summarization emit summarization events."""
agent = FakeV3Agent(
[
@@ -265,7 +260,7 @@ class TestV3ProtocolStreaming:
message_delta("real content"),
]
)
events = await collect_events(agent)
events = collect_events(agent)
summary_start_events = [
e for e in events if e.get("type") == "summarization_start"
]
@@ -277,7 +272,7 @@ class TestV3ProtocolStreaming:
assert len(text_events) == 1
assert text_events[0]["content"] == "real content"
async def test_updates_mode_summarization_event_emitted(self):
def test_updates_mode_summarization_event_emitted(self):
"""_summarization_event updates should emit a summarization event."""
summary_message = HumanMessage(
content="Here is a summary of the conversation to date:\n\nKey facts",
@@ -299,7 +294,7 @@ class TestV3ProtocolStreaming:
message_delta("real content"),
]
)
events = await collect_events(agent)
events = collect_events(agent)
summary_start_events = [
e for e in events if e.get("type") == "summarization_start"
]
@@ -308,7 +303,7 @@ class TestV3ProtocolStreaming:
assert len(summary_events) == 1
assert summary_events[0]["content"] == "Key facts"
async def test_updates_mode_does_not_duplicate_streamed_summarization(self):
def test_updates_mode_does_not_duplicate_streamed_summarization(self):
"""If streamed summarization already emitted, updates fallback should not duplicate it."""
summary_message = HumanMessage(
content="Here is a summary of the conversation to date:\n\nKey facts"
@@ -329,7 +324,7 @@ class TestV3ProtocolStreaming:
message_delta("real content"),
]
)
events = await collect_events(agent)
events = collect_events(agent)
summary_start_events = [
e for e in events if e.get("type") == "summarization_start"
]
@@ -338,7 +333,7 @@ class TestV3ProtocolStreaming:
assert len(summary_events) == 1
assert summary_events[0]["content"] == "synthetic summary"
async def test_updates_mode_does_not_reemit_existing_summarization_event(self):
def test_updates_mode_does_not_reemit_existing_summarization_event(self):
"""Persisted _summarization_event from a prior turn should not be replayed."""
summary_message = HumanMessage(
content="Here is a summary of the conversation to date:\n\nKey facts",
@@ -357,7 +352,7 @@ class TestV3ProtocolStreaming:
],
state_values=summary_event,
)
events = await collect_events(agent)
events = collect_events(agent)
summary_start_events = [
e for e in events if e.get("type") == "summarization_start"
]
@@ -365,7 +360,7 @@ class TestV3ProtocolStreaming:
summary_events = [e for e in events if e.get("type") == "summarization"]
assert summary_events == []
async def test_direct_stream_loads_existing_summarization_event_when_omitted(self):
def test_direct_stream_loads_existing_summarization_event_when_omitted(self):
"""Public stream_agent_events() suppresses persisted summary replays."""
summary_message = HumanMessage(
content="Here is a summary of the conversation to date:\n\nKey facts",
@@ -385,9 +380,13 @@ class TestV3ProtocolStreaming:
state_values=summary_event,
)
events = []
async for event in stream_agent_events(agent, "hi", "t1"):
events.append(event)
async def _collect():
events = []
async for event in stream_agent_events(agent, "hi", "t1"):
events.append(event)
return events
events = run_async(_collect())
summary_start_events = [
e for e in events if e.get("type") == "summarization_start"
@@ -396,19 +395,19 @@ class TestV3ProtocolStreaming:
summary_events = [e for e in events if e.get("type") == "summarization"]
assert summary_events == []
async def test_whole_message_reasoning_is_not_duplicated(self):
def test_whole_message_reasoning_is_not_duplicated(self):
"""Providers can expose the same reasoning in kwargs and content blocks."""
message = AIMessage(
additional_kwargs={"reasoning_content": "Think once."},
content=[{"type": "reasoning", "reasoning": "Think once."}],
)
agent = FakeV3Agent([protocol_event("messages", (message, {}))])
events = await collect_events(agent)
events = collect_events(agent)
thinking_events = [e for e in events if e.get("type") == "thinking"]
assert len(thinking_events) == 1
assert thinking_events[0]["content"] == "Think once."
async def test_tool_selector_reasoning_delta_is_suppressed(self):
def test_tool_selector_reasoning_delta_is_suppressed(self):
"""Selector reasoning must not appear as main-agent thinking."""
import EvoScientist.middleware.tool_selector as selector_mod
@@ -433,7 +432,7 @@ class TestV3ProtocolStreaming:
)
]
)
events = await collect_events(agent)
events = collect_events(agent)
finally:
selector_mod._selector_active = original_active
@@ -442,7 +441,7 @@ class TestV3ProtocolStreaming:
for e in events
)
async def test_tool_selector_whole_message_reasoning_is_suppressed(self):
def test_tool_selector_whole_message_reasoning_is_suppressed(self):
"""Selector reasoning in whole-message payloads is also hidden."""
import EvoScientist.middleware.tool_selector as selector_mod
@@ -454,7 +453,7 @@ class TestV3ProtocolStreaming:
content="",
)
agent = FakeV3Agent([protocol_event("messages", (message, {}))])
events = await collect_events(agent)
events = collect_events(agent)
finally:
selector_mod._selector_active = original_active
@@ -463,7 +462,7 @@ class TestV3ProtocolStreaming:
for e in events
)
async def test_tool_events_emit_call_and_result(self):
def test_tool_events_emit_call_and_result(self):
"""v3 tool projection events become UI tool call/result events."""
output = ToolMessage(
name="read_file",
@@ -476,7 +475,7 @@ class TestV3ProtocolStreaming:
tool_finished(output),
]
)
events = await collect_events(agent)
events = collect_events(agent)
tool_call = next(e for e in events if e.get("type") == "tool_call")
tool_result = next(e for e in events if e.get("type") == "tool_result")
assert tool_call["name"] == "read_file"
@@ -490,7 +489,7 @@ class TestV3ProtocolStreaming:
@pytest.mark.filterwarnings(
"ignore:The v3 streaming protocol on Pregel is experimental"
)
async def test_live_deepagents_v3_tool_result_preserves_tool_call_id(self):
def test_live_deepagents_v3_tool_result_preserves_tool_call_id(self):
"""DeepAgents v3 emits tool_call_id on started and finished tool events."""
@tool
@@ -519,14 +518,17 @@ class TestV3ProtocolStreaming:
system_prompt="Use tools when requested.",
)
events = [
event
async for event in stream_agent_events(
agent,
"run probe",
"live-deepagents-tool-id",
)
]
async def _collect_events():
return [
event
async for event in stream_agent_events(
agent,
"run probe",
"live-deepagents-tool-id",
)
]
events = run_async(_collect_events())
tool_call = next(e for e in events if e.get("type") == "tool_call")
tool_result = next(e for e in events if e.get("type") == "tool_result")
@@ -549,7 +551,7 @@ class TestV3ProtocolStreaming:
@pytest.mark.filterwarnings(
"ignore:The v3 streaming protocol on Pregel is experimental"
)
async def test_live_deepagents_v3_hitl_emits_tool_call_and_single_interrupt(self):
def test_live_deepagents_v3_hitl_emits_tool_call_and_single_interrupt(self):
"""Live HITL streams the model tool call once before one interrupt."""
@tool
@@ -579,14 +581,17 @@ class TestV3ProtocolStreaming:
checkpointer=InMemorySaver(),
)
events = [
event
async for event in stream_agent_events(
agent,
"run echo",
"live-deepagents-hitl",
)
]
async def _collect_events():
return [
event
async for event in stream_agent_events(
agent,
"run echo",
"live-deepagents-hitl",
)
]
events = run_async(_collect_events())
tool_calls = [e for e in events if e.get("type") == "tool_call"]
interrupts = [e for e in events if e.get("type") == "interrupt"]
@@ -606,7 +611,7 @@ class TestV3ProtocolStreaming:
@pytest.mark.filterwarnings(
"ignore:The v3 streaming protocol on Pregel is experimental"
)
async def test_live_deepagents_v3_ask_user_suppresses_interrupt_tool_result(self):
def test_live_deepagents_v3_ask_user_suppresses_interrupt_tool_result(self):
"""ask_user pause markers are not displayed as failed tool results."""
model = _ToolCallingFakeModel(
@@ -649,15 +654,15 @@ class TestV3ProtocolStreaming:
)
]
first_events = await _collect("ask")
first_events = run_async(_collect("ask"))
first_types = [event.get("type") for event in first_events]
assert first_types == ["tool_call", "ask_user", "done"]
ask_event = next(e for e in first_events if e.get("type") == "ask_user")
assert ask_event["tool_call_id"] == "call_ask_1"
assert ask_event["questions"] == [{"question": "What dataset?", "type": "text"}]
resumed_events = await _collect(
Command(resume={"answers": ["CIFAR-10"], "status": "answered"})
resumed_events = run_async(
_collect(Command(resume={"answers": ["CIFAR-10"], "status": "answered"}))
)
tool_result = next(e for e in resumed_events if e.get("type") == "tool_result")
assert tool_result == {
@@ -673,9 +678,7 @@ class TestV3ProtocolStreaming:
@pytest.mark.filterwarnings(
"ignore:The v3 streaming protocol on Pregel is experimental"
)
async def test_live_deepagents_v3_task_result_uses_subagent_tool_message_content(
self,
):
def test_live_deepagents_v3_task_result_uses_subagent_tool_message_content(self):
"""Live task results should display the subagent ToolMessage content."""
root_model = _ToolCallingFakeModel(
@@ -714,14 +717,17 @@ class TestV3ProtocolStreaming:
],
)
events = [
event
async for event in stream_agent_events(
agent,
"delegate",
"live-deepagents-subagent",
)
]
async def _collect_events():
return [
event
async for event in stream_agent_events(
agent,
"delegate",
"live-deepagents-subagent",
)
]
events = run_async(_collect_events())
subagent_start = next(e for e in events if e.get("type") == "subagent_start")
subagent_end = next(e for e in events if e.get("type") == "subagent_end")
@@ -739,7 +745,7 @@ class TestV3ProtocolStreaming:
assert task_result["content"] == "subagent final"
assert "Command(" not in task_result["content"]
async def test_message_tool_call_block_emits_pre_execution_tool_call(self):
def test_message_tool_call_block_emits_pre_execution_tool_call(self):
"""Model-declared tool calls remain visible before execution starts."""
agent = FakeV3Agent(
[
@@ -770,14 +776,14 @@ class TestV3ProtocolStreaming:
),
]
)
events = await collect_events(agent)
events = collect_events(agent)
event_types = [e["type"] for e in events]
assert event_types.index("tool_call") < event_types.index("interrupt")
tool_call = next(e for e in events if e.get("type") == "tool_call")
assert tool_call["id"] == "tc-msg"
assert tool_call["args"] == {"command": "ls"}
async def test_tool_selection_flushes_before_tool_only_step(self):
def test_tool_selection_flushes_before_tool_only_step(self):
"""Selector UI event is emitted even when selection is followed only by a tool."""
import EvoScientist.middleware.tool_selector as selector_mod
@@ -800,7 +806,7 @@ class TestV3ProtocolStreaming:
tool_finished(output),
]
)
events = await collect_events(agent)
events = collect_events(agent)
finally:
selector_mod._current_selected_tools = original_selected
selector_mod._total_tools_count = original_total
@@ -811,7 +817,7 @@ class TestV3ProtocolStreaming:
selection = next(e for e in events if e.get("type") == "tool_selection")
assert selection["tools"] == ["read_file"]
async def test_subagent_projection_routes_namespaced_events(self):
def test_subagent_projection_routes_namespaced_events(self):
"""DeepAgents subagent projection supplies identity for namespaced events."""
namespace = ("task", "abc")
output = ToolMessage(
@@ -832,7 +838,7 @@ class TestV3ProtocolStreaming:
],
subagents=[FakeSubagent(namespace, "research-agent")],
)
events = await collect_events(agent)
events = collect_events(agent)
assert any(e.get("type") == "subagent_start" for e in events)
assert any(e.get("type") == "subagent_end" for e in events)
@@ -859,7 +865,7 @@ class TestV3ProtocolStreaming:
)
assert event_types.index("subagent_end") < event_types.index("done")
async def test_namespaced_events_wait_for_delayed_subagent_registration(self):
def test_namespaced_events_wait_for_delayed_subagent_registration(self):
"""Subagent events are not dropped if protocol events arrive first."""
namespace = ("task", "late")
@@ -894,7 +900,7 @@ class TestV3ProtocolStreaming:
return Snapshot()
events = await collect_events(Agent())
events = collect_events(Agent())
event_types = [e["type"] for e in events]
text = next(e for e in events if e.get("type") == "subagent_text")
@@ -902,7 +908,7 @@ class TestV3ProtocolStreaming:
assert text["instance_id"] == "task:late"
assert event_types.index("subagent_start") < event_types.index("subagent_text")
async def test_subagent_tool_dedupe_uses_resolved_path(self):
def test_subagent_tool_dedupe_uses_resolved_path(self):
"""Tool call/result events can arrive on namespace suffixes for one subagent."""
subagent_path = ("task", "abc")
call_namespace = (*subagent_path, "agent")
@@ -930,7 +936,7 @@ class TestV3ProtocolStreaming:
],
subagents=[FakeSubagent(subagent_path, "research-agent")],
)
events = await collect_events(agent)
events = collect_events(agent)
calls = [e for e in events if e.get("type") == "subagent_tool_call"]
results = [e for e in events if e.get("type") == "subagent_tool_result"]
@@ -942,7 +948,7 @@ class TestV3ProtocolStreaming:
assert results[0]["instance_id"] == "task:abc"
assert results[0]["id"] == "sa-tc"
async def test_subagent_end_is_emitted_before_later_root_text(self):
def test_subagent_end_is_emitted_before_later_root_text(self):
"""Finished subagents stop showing as active while root streaming continues."""
output_returned = asyncio.Event()
@@ -990,19 +996,19 @@ class TestV3ProtocolStreaming:
return Snapshot()
events = await collect_events(Agent())
events = collect_events(Agent())
event_types = [e["type"] for e in events]
assert event_types.index("subagent_end") < event_types.index("text")
async def test_subagent_projection_is_subscribed_before_protocol_pump(self):
def test_subagent_projection_is_subscribed_before_protocol_pump(self):
"""Subagent handles are not dropped by lazy projection subscription."""
namespace = ("task", "early")
agent = SubscriptionSensitiveV3Agent(
[message_delta("Sub-agent finding.", namespace=namespace)],
[FakeSubagent(namespace, "research-agent")],
)
events = await collect_events(agent)
events = collect_events(agent)
assert any(e.get("type") == "subagent_start" for e in events)
assert any(e.get("type") == "subagent_end" for e in events)
assert [e for e in events if e.get("type") == "text"] == []
@@ -1012,7 +1018,7 @@ class TestV3ProtocolStreaming:
assert text["content"] == "Sub-agent finding."
assert text["instance_id"] == "task:early"
async def test_parallel_same_name_subagent_events_carry_instance_ids(self):
def test_parallel_same_name_subagent_events_carry_instance_ids(self):
"""Lifecycle and tool events distinguish same-name parallel subagents."""
ns1 = ("task", "one")
ns2 = ("task", "two")
@@ -1042,7 +1048,7 @@ class TestV3ProtocolStreaming:
FakeSubagent(ns2, "research-agent"),
],
)
events = await collect_events(agent)
events = collect_events(agent)
starts = [e for e in events if e.get("type") == "subagent_start"]
calls = [e for e in events if e.get("type") == "subagent_tool_call"]
@@ -1054,7 +1060,7 @@ class TestV3ProtocolStreaming:
assert {e["instance_id"] for e in results} == {"task:one", "task:two"}
assert {e["instance_id"] for e in ends} == {"task:one", "task:two"}
async def test_stream_construction_error_emits_error_before_reraising(self):
def test_stream_construction_error_emits_error_before_reraising(self):
"""astream_events construction failures preserve the UI error event contract."""
events = []
@@ -1067,10 +1073,10 @@ class TestV3ProtocolStreaming:
events.append(ev)
with pytest.raises(RuntimeError, match="boom"):
await collect()
run_async(collect())
assert events == [{"type": "error", "message": "boom"}]
async def test_generator_close_aborts_underlying_v3_stream(self):
def test_generator_close_aborts_underlying_v3_stream(self):
"""Early consumer exit should abort the caller-driven v3 run."""
async def consume_one_and_close():
@@ -1084,7 +1090,7 @@ class TestV3ProtocolStreaming:
await stream.aclose()
return first, agent.aborted
first, aborted = await consume_one_and_close()
first, aborted = run_async(consume_one_and_close())
assert first["type"] == "text"
assert first["content"] == "hi"
assert aborted is True
@@ -1093,7 +1099,7 @@ class TestV3ProtocolStreaming:
class TestUsageStatsExtraction:
"""Test token usage extraction from v3 message-finish events."""
async def test_usage_metadata_emitted(self):
def test_usage_metadata_emitted(self):
"""v3 message-finish usage emits usage_stats event."""
agent = FakeV3Agent(
[
@@ -1107,16 +1113,16 @@ class TestUsageStatsExtraction:
),
]
)
events = await collect_events(agent)
events = collect_events(agent)
usage_events = [e for e in events if e.get("type") == "usage_stats"]
assert len(usage_events) == 1
assert usage_events[0]["input_tokens"] == 100
assert usage_events[0]["output_tokens"] == 50
async def test_no_usage_metadata_no_event(self):
def test_no_usage_metadata_no_event(self):
"""message-finish without usage does not emit usage_stats."""
agent = FakeV3Agent([message_delta("hi"), message_finish()])
events = await collect_events(agent)
events = collect_events(agent)
usage_events = [e for e in events if e.get("type") == "usage_stats"]
assert len(usage_events) == 0
@@ -1152,7 +1158,7 @@ class TestSummarizationHelpers:
assert isinstance(summary_message, HumanMessage)
assert summary_message.content == "Summary body"
async def test_zero_tokens_not_emitted(self):
def test_zero_tokens_not_emitted(self):
"""Zero input and output tokens should not emit usage_stats."""
agent = FakeV3Agent(
[
@@ -1162,6 +1168,6 @@ class TestSummarizationHelpers:
),
]
)
events = await collect_events(agent)
events = collect_events(agent)
usage_events = [e for e in events if e.get("type") == "usage_stats"]
assert len(usage_events) == 0
+5 -4
View File
@@ -16,6 +16,7 @@ from langgraph.graph import END, START, StateGraph
from langgraph.types import interrupt
from EvoScientist.stream.events import _clear_interrupted_graph_state
from tests.conftest import run_async as _run
class _S(TypedDict):
@@ -57,7 +58,7 @@ def _interrupting_app():
return g.compile(checkpointer=InMemorySaver())
async def test_recovery_clears_stuck_state_after_crash():
def test_recovery_clears_stuck_state_after_crash():
app = _crashing_app()
cfg = {"configurable": {"thread_id": "t1"}}
try:
@@ -67,7 +68,7 @@ async def test_recovery_clears_stuck_state_after_crash():
# The crash left the graph frozen at node 'b'.
assert app.get_state(cfg).next == ("b",)
await _clear_interrupted_graph_state(app, cfg)
_run(_clear_interrupted_graph_state(app, cfg))
snap = app.get_state(cfg)
assert snap.next == () # stuck state actually cleared
@@ -78,7 +79,7 @@ async def test_recovery_clears_stuck_state_after_crash():
assert app.invoke({"x": 41}, cfg)["x"] == 142
async def test_recovery_preserves_pending_hitl_interrupt():
def test_recovery_preserves_pending_hitl_interrupt():
app = _interrupting_app()
cfg = {"configurable": {"thread_id": "t1"}}
app.invoke({"x": 0}, cfg) # parks at interrupt()
@@ -86,7 +87,7 @@ async def test_recovery_preserves_pending_hitl_interrupt():
assert before.next == ("ask",)
assert before.interrupts
await _clear_interrupted_graph_state(app, cfg)
_run(_clear_interrupted_graph_state(app, cfg))
after = app.get_state(cfg)
assert after.next == ("ask",) # interrupt left intact, still resumable
+42 -31
View File
@@ -12,6 +12,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from EvoScientist.stt import STT_MODELS, is_audio_file, transcribe_file
from tests.conftest import run_async
# ── is_audio_file ─────────────────────────────────────────────────────
@@ -49,8 +50,8 @@ def test_stt_models_keys():
# ── transcribe_file: non-audio path ──────────────────────────────────
async def test_transcribe_non_audio_returns_none():
result = await transcribe_file("photo.jpg", language="auto")
def test_transcribe_non_audio_returns_none():
result = run_async(transcribe_file("photo.jpg", language="auto"))
assert result is None
@@ -77,37 +78,37 @@ def _patch_whisper(whisper_model):
)
async def test_transcribe_en_uses_whisper():
def test_transcribe_en_uses_whisper():
import EvoScientist.stt as stt_mod
stt_mod._engine = None
with _patch_whisper(_make_whisper_mock("Hello world")):
result = await transcribe_file("voice.mp3", language="en")
result = run_async(transcribe_file("voice.mp3", language="en"))
stt_mod._engine = None
assert result == "Hello world"
async def test_transcribe_auto_uses_whisper():
def test_transcribe_auto_uses_whisper():
import EvoScientist.stt as stt_mod
stt_mod._engine = None
with _patch_whisper(_make_whisper_mock("Bonjour monde")):
result = await transcribe_file("voice.ogg", language="auto")
result = run_async(transcribe_file("voice.ogg", language="auto"))
stt_mod._engine = None
assert result == "Bonjour monde"
async def test_transcribe_zh_uses_whisper():
def test_transcribe_zh_uses_whisper():
import EvoScientist.stt as stt_mod
stt_mod._engine = None
with _patch_whisper(_make_whisper_mock("你好世界")):
result = await transcribe_file("voice.ogg", language="zh")
result = run_async(transcribe_file("voice.ogg", language="zh"))
stt_mod._engine = None
assert result == "你好世界"
async def test_transcribe_custom_model_override():
def test_transcribe_custom_model_override():
"""stt_model config overrides the default model mapping."""
import EvoScientist.stt as stt_mod
@@ -120,8 +121,10 @@ async def test_transcribe_custom_model_override():
self._model = _make_whisper_mock("test")
with patch.object(stt_mod._WhisperEngine, "__init__", patched_init):
await transcribe_file(
"voice.ogg", language="auto", model="openai/whisper-large-v3"
run_async(
transcribe_file(
"voice.ogg", language="auto", model="openai/whisper-large-v3"
)
)
stt_mod._engine = None
assert captured_model_id == ["openai/whisper-large-v3"]
@@ -130,7 +133,7 @@ async def test_transcribe_custom_model_override():
# ── transcribe_file: missing dependency ──────────────────────────────
async def test_transcribe_missing_dep_returns_none():
def test_transcribe_missing_dep_returns_none():
import sys
import EvoScientist.stt as stt_mod
@@ -139,7 +142,7 @@ async def test_transcribe_missing_dep_returns_none():
saved = sys.modules.pop("faster_whisper", None)
try:
with patch.dict("sys.modules", {"faster_whisper": None}):
result = await transcribe_file("voice.mp3", language="auto")
result = run_async(transcribe_file("voice.mp3", language="auto"))
finally:
if saved is not None:
sys.modules["faster_whisper"] = saved
@@ -166,7 +169,7 @@ def _make_channel():
return ch, captured
async def test_enqueue_raw_stt_prepends_transcript():
def test_enqueue_raw_stt_prepends_transcript():
"""_enqueue_raw prepends STT transcript to raw.text when stt_enabled."""
from EvoScientist.channels.base import RawIncoming
@@ -186,18 +189,22 @@ async def test_enqueue_raw_stt_prepends_transcript():
timestamp=datetime.now(),
)
with (
patch("EvoScientist.stt.transcribe_file", new=AsyncMock(return_value="你好")),
patch("EvoScientist.stt.is_audio_file", return_value=True),
):
await ch._enqueue_raw(raw)
async def _run():
with (
patch(
"EvoScientist.stt.transcribe_file", new=AsyncMock(return_value="你好")
),
patch("EvoScientist.stt.is_audio_file", return_value=True),
):
await ch._enqueue_raw(raw)
run_async(_run())
assert captured[0].text == "你好"
# annotation should be removed after transcription
assert captured[0].content_annotations == []
async def test_enqueue_raw_stt_disabled_skips_transcription():
def test_enqueue_raw_stt_disabled_skips_transcription():
"""When stt_enabled=False, transcription is not called."""
from EvoScientist.channels.base import RawIncoming
@@ -214,14 +221,16 @@ async def test_enqueue_raw_stt_disabled_skips_transcription():
mock_transcribe = AsyncMock()
with patch("EvoScientist.stt.transcribe_file", mock_transcribe):
await ch._enqueue_raw(raw)
async def _run():
with patch("EvoScientist.stt.transcribe_file", mock_transcribe):
await ch._enqueue_raw(raw)
run_async(_run())
mock_transcribe.assert_not_called()
assert captured[0].text == ""
async def test_enqueue_raw_stt_appends_to_existing_text():
def test_enqueue_raw_stt_appends_to_existing_text():
"""Transcript is prepended before any existing caption text."""
from EvoScientist.channels.base import RawIncoming
@@ -240,15 +249,17 @@ async def test_enqueue_raw_stt_appends_to_existing_text():
timestamp=datetime.now(),
)
with (
patch(
"EvoScientist.stt.transcribe_file",
new=AsyncMock(return_value="hello world"),
),
patch("EvoScientist.stt.is_audio_file", return_value=True),
):
await ch._enqueue_raw(raw)
async def _run():
with (
patch(
"EvoScientist.stt.transcribe_file",
new=AsyncMock(return_value="hello world"),
),
patch("EvoScientist.stt.is_audio_file", return_value=True),
):
await ch._enqueue_raw(raw)
run_async(_run())
assert captured[0].text == "hello world\ncaption text"
+172 -144
View File
@@ -17,6 +17,7 @@ from EvoScientist.channels.bus.message_bus import MessageBus
from EvoScientist.channels.channel_manager import ChannelManager
from EvoScientist.channels.consumer import InboundConsumer, _join_subagent_text
from EvoScientist.stream.emitter import StreamEvent, StreamEventEmitter
from tests.conftest import run_async as _run
from tests.fakes import FakeGraphGateway
from tests.fakes import StubChannel as _StubChannel
from tests.stream_v3_fakes import (
@@ -81,7 +82,7 @@ class TestSubagentTextEmitter:
class TestStreamAgentEventsSubagentText:
"""Verify sub-agent text chunks yield subagent_text events."""
async def test_subagent_text_emitted_for_subagent_chunks(self):
def test_subagent_text_emitted_for_subagent_chunks(self):
"""When a sub-agent produces text, subagent_text events should appear."""
namespace = ("sub", "research")
agent = FakeV3Agent(
@@ -92,23 +93,23 @@ class TestStreamAgentEventsSubagentText:
],
subagents=[FakeSubagent(namespace, "research-agent")],
)
events = await collect_events(agent)
events = collect_events(agent)
sa_text = [e for e in events if e.get("type") == "subagent_text"]
assert len(sa_text) == 1
assert "Sub-agent finding" in sa_text[0]["content"]
# instance_id must be present and non-empty
assert sa_text[0].get("instance_id"), "instance_id must be a non-empty string"
async def test_subagent_text_not_emitted_for_main_agent(self):
def test_subagent_text_not_emitted_for_main_agent(self):
"""Main agent text should produce 'text' events, not 'subagent_text'."""
agent = FakeV3Agent([message_delta("Main agent reply.")])
events = await collect_events(agent)
events = collect_events(agent)
sa_text = [e for e in events if e.get("type") == "subagent_text"]
text_events = [e for e in events if e.get("type") == "text"]
assert len(sa_text) == 0
assert len(text_events) == 1
async def test_multiple_subagent_text_chunks_all_emitted(self):
def test_multiple_subagent_text_chunks_all_emitted(self):
"""Multiple text chunks from a sub-agent all yield subagent_text events."""
namespace = ("sub", "a")
agent = FakeV3Agent(
@@ -119,7 +120,7 @@ class TestStreamAgentEventsSubagentText:
],
subagents=[FakeSubagent(namespace, "research-agent")],
)
events = await collect_events(agent)
events = collect_events(agent)
sa_text = [e for e in events if e.get("type") == "subagent_text"]
assert len(sa_text) == 3
combined = "".join(e["content"] for e in sa_text)
@@ -131,7 +132,7 @@ class TestStreamAgentEventsSubagentText:
assert len(ids) == 1, f"Expected 1 unique instance_id, got {ids}"
assert all(e.get("instance_id") for e in sa_text)
async def test_parallel_same_name_agents_get_distinct_instance_ids(self):
def test_parallel_same_name_agents_get_distinct_instance_ids(self):
"""Two sub-agents with the same display name but different namespaces
produce subagent_text events with different instance_id values.
@@ -153,7 +154,7 @@ class TestStreamAgentEventsSubagentText:
FakeSubagent(ns2, "research-agent"),
],
)
events = await collect_events(agent)
events = collect_events(agent)
sa_text = [e for e in events if e.get("type") == "subagent_text"]
assert len(sa_text) == 3
@@ -210,7 +211,7 @@ def _make_consumer(stream_events: list[dict], **kw):
class TestConsumerSubagentTextFallback:
"""InboundConsumer should use sub-agent text as fallback when main agent is silent."""
async def test_subagent_text_used_when_no_final_content(self):
def test_subagent_text_used_when_no_final_content(self):
"""When the main agent produces no text, sub-agent text becomes the response."""
events = [
{
@@ -229,24 +230,27 @@ class TestConsumerSubagentTextFallback:
]
consumer, bus = _make_consumer(events)
msg = BusInbound(
channel="stub",
sender_id="u1",
chat_id="c1",
content="analyze papers",
)
await bus.publish_inbound(msg)
async def _test():
msg = BusInbound(
channel="stub",
sender_id="u1",
chat_id="c1",
content="analyze papers",
)
await bus.publish_inbound(msg)
task = asyncio.create_task(consumer.run())
outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=5.0)
task = asyncio.create_task(consumer.run())
outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=5.0)
assert outbound.content == "Found 3 relevant papers. Key insight: X is Y."
assert outbound.channel == "stub"
assert outbound.content == "Found 3 relevant papers. Key insight: X is Y."
assert outbound.channel == "stub"
await consumer.stop()
await task
await consumer.stop()
await task
async def test_final_content_takes_priority_over_subagent_text(self):
_run(_test())
def test_final_content_takes_priority_over_subagent_text(self):
"""When the main agent produces text, sub-agent text is ignored."""
events = [
{
@@ -260,23 +264,26 @@ class TestConsumerSubagentTextFallback:
]
consumer, bus = _make_consumer(events)
msg = BusInbound(
channel="stub",
sender_id="u1",
chat_id="c1",
content="test",
)
await bus.publish_inbound(msg)
async def _test():
msg = BusInbound(
channel="stub",
sender_id="u1",
chat_id="c1",
content="test",
)
await bus.publish_inbound(msg)
task = asyncio.create_task(consumer.run())
outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=5.0)
task = asyncio.create_task(consumer.run())
outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=5.0)
assert outbound.content == "Here is my summary."
assert outbound.content == "Here is my summary."
await consumer.stop()
await task
await consumer.stop()
await task
async def test_duplicate_thinking_not_relayed_across_resume_rounds(self):
_run(_test())
def test_duplicate_thinking_not_relayed_across_resume_rounds(self):
"""Repeated thinking from resumed rounds should only be sent once."""
bus = MessageBus()
mgr = ChannelManager(bus)
@@ -322,27 +329,30 @@ class TestConsumerSubagentTextFallback:
return_value={"answers": ["yes"], "status": "answered"}
)
await bus.publish_inbound(
BusInbound(
channel="stub",
sender_id="u1",
chat_id="c1",
content="analyze papers",
async def _test():
await bus.publish_inbound(
BusInbound(
channel="stub",
sender_id="u1",
chat_id="c1",
content="analyze papers",
)
)
)
task = asyncio.create_task(consumer.run())
outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=5.0)
task = asyncio.create_task(consumer.run())
outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=5.0)
assert outbound.content == "final answer"
assert channel.send_thinking_message.await_count == 1
call = channel.send_thinking_message.await_args_list[0]
assert call.args[1] == thinking.rstrip()
assert outbound.content == "final answer"
assert channel.send_thinking_message.await_count == 1
call = channel.send_thinking_message.await_args_list[0]
assert call.args[1] == thinking.rstrip()
await consumer.stop()
await task
await consumer.stop()
await task
async def test_new_thinking_relayed_after_resume(self):
_run(_test())
def test_new_thinking_relayed_after_resume(self):
"""Genuinely different thinking in round 2 should be sent."""
bus = MessageBus()
mgr = ChannelManager(bus)
@@ -389,52 +399,58 @@ class TestConsumerSubagentTextFallback:
return_value={"answers": ["yes"], "status": "answered"}
)
await bus.publish_inbound(
BusInbound(
channel="stub",
sender_id="u1",
chat_id="c1",
content="analyze papers",
async def _test():
await bus.publish_inbound(
BusInbound(
channel="stub",
sender_id="u1",
chat_id="c1",
content="analyze papers",
)
)
)
task = asyncio.create_task(consumer.run())
outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=5.0)
task = asyncio.create_task(consumer.run())
outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=5.0)
assert outbound.content == "final answer"
assert channel.send_thinking_message.await_count == 2
call1 = channel.send_thinking_message.await_args_list[0]
call2 = channel.send_thinking_message.await_args_list[1]
assert call1.args[1] == thinking_r1.rstrip()
assert call2.args[1] == thinking_r2.rstrip()
assert outbound.content == "final answer"
assert channel.send_thinking_message.await_count == 2
call1 = channel.send_thinking_message.await_args_list[0]
call2 = channel.send_thinking_message.await_args_list[1]
assert call1.args[1] == thinking_r1.rstrip()
assert call2.args[1] == thinking_r2.rstrip()
await consumer.stop()
await task
await consumer.stop()
await task
async def test_no_response_fallback_when_both_empty(self):
_run(_test())
def test_no_response_fallback_when_both_empty(self):
"""When both final_content and subagent_text are empty, 'No response' is used."""
events = [
{"type": "done", "content": ""},
]
consumer, bus = _make_consumer(events)
msg = BusInbound(
channel="stub",
sender_id="u1",
chat_id="c1",
content="test",
)
await bus.publish_inbound(msg)
async def _test():
msg = BusInbound(
channel="stub",
sender_id="u1",
chat_id="c1",
content="test",
)
await bus.publish_inbound(msg)
task = asyncio.create_task(consumer.run())
outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=5.0)
task = asyncio.create_task(consumer.run())
outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=5.0)
assert outbound.content == "No response"
assert outbound.content == "No response"
await consumer.stop()
await task
await consumer.stop()
await task
async def test_done_content_overrides_subagent_text(self):
_run(_test())
def test_done_content_overrides_subagent_text(self):
"""Done event with content takes priority over sub-agent text buffer."""
events = [
{
@@ -447,21 +463,24 @@ class TestConsumerSubagentTextFallback:
]
consumer, bus = _make_consumer(events)
msg = BusInbound(
channel="stub",
sender_id="u1",
chat_id="c1",
content="test",
)
await bus.publish_inbound(msg)
async def _test():
msg = BusInbound(
channel="stub",
sender_id="u1",
chat_id="c1",
content="test",
)
await bus.publish_inbound(msg)
task = asyncio.create_task(consumer.run())
outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=5.0)
task = asyncio.create_task(consumer.run())
outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=5.0)
assert outbound.content == "Final summary from done event."
assert outbound.content == "Final summary from done event."
await consumer.stop()
await task
await consumer.stop()
await task
_run(_test())
# ═══════════════════════════════════════════════════════════════════
@@ -541,7 +560,7 @@ class TestJoinSubagentText:
class TestConsumerParallelSubagentFallback:
"""Consumer should group parallel sub-agent text by agent name."""
async def test_parallel_agents_grouped_with_attribution(self):
def test_parallel_agents_grouped_with_attribution(self):
"""Multiple sub-agents produce grouped, attributed output."""
events = [
{
@@ -566,24 +585,27 @@ class TestConsumerParallelSubagentFallback:
]
consumer, bus = _make_consumer(events)
msg = BusInbound(
channel="stub",
sender_id="u1",
chat_id="c1",
content="test",
)
await bus.publish_inbound(msg)
async def _test():
msg = BusInbound(
channel="stub",
sender_id="u1",
chat_id="c1",
content="test",
)
await bus.publish_inbound(msg)
task = asyncio.create_task(consumer.run())
outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=5.0)
task = asyncio.create_task(consumer.run())
outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=5.0)
assert "[research]: Found papers. Key insight." in outbound.content
assert "[analysis]: Metric is high." in outbound.content
assert "[research]: Found papers. Key insight." in outbound.content
assert "[analysis]: Metric is high." in outbound.content
await consumer.stop()
await task
await consumer.stop()
await task
async def test_single_agent_no_attribution_prefix(self):
_run(_test())
def test_single_agent_no_attribution_prefix(self):
"""Single sub-agent fallback has no [name]: prefix."""
events = [
{
@@ -596,28 +618,31 @@ class TestConsumerParallelSubagentFallback:
]
consumer, bus = _make_consumer(events)
msg = BusInbound(
channel="stub",
sender_id="u1",
chat_id="c1",
content="test",
)
await bus.publish_inbound(msg)
async def _test():
msg = BusInbound(
channel="stub",
sender_id="u1",
chat_id="c1",
content="test",
)
await bus.publish_inbound(msg)
task = asyncio.create_task(consumer.run())
outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=5.0)
task = asyncio.create_task(consumer.run())
outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=5.0)
assert outbound.content == "Only agent."
assert "[research]" not in outbound.content
assert outbound.content == "Only agent."
assert "[research]" not in outbound.content
await consumer.stop()
await task
await consumer.stop()
await task
_run(_test())
class TestConsumerSameNameInterleaved:
"""Two instances of the same agent type with interleaved chunks."""
async def test_same_name_interleaved_chunks_separated_by_instance_id(self):
def test_same_name_interleaved_chunks_separated_by_instance_id(self):
"""Two research-agent instances with different instance_ids are properly separated.
With the instance_id fix, chunks are keyed by instance_id so
@@ -653,29 +678,32 @@ class TestConsumerSameNameInterleaved:
]
consumer, bus = _make_consumer(events)
msg = BusInbound(
channel="stub",
sender_id="u1",
chat_id="c1",
content="test",
)
await bus.publish_inbound(msg)
async def _test():
msg = BusInbound(
channel="stub",
sender_id="u1",
chat_id="c1",
content="test",
)
await bus.publish_inbound(msg)
task = asyncio.create_task(consumer.run())
outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=5.0)
task = asyncio.create_task(consumer.run())
outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=5.0)
# Fixed: instances are now properly separated with numbered labels
assert (
"[research-agent #1]: Instance-1 sentence A. Instance-1 sentence B."
in outbound.content
)
assert (
"[research-agent #2]: Instance-2 sentence X. Instance-2 sentence Y."
in outbound.content
)
# Fixed: instances are now properly separated with numbered labels
assert (
"[research-agent #1]: Instance-1 sentence A. Instance-1 sentence B."
in outbound.content
)
assert (
"[research-agent #2]: Instance-2 sentence X. Instance-2 sentence Y."
in outbound.content
)
await consumer.stop()
await task
await consumer.stop()
await task
_run(_test())
class TestDelegationPromptSummarize:
+7 -6
View File
@@ -4,6 +4,7 @@ import pytest
from EvoScientist.channels.base import ChannelError
from EvoScientist.channels.telegram.channel import TelegramChannel, TelegramConfig
from tests.conftest import run_async as _run
class TestTelegramConfig:
@@ -31,18 +32,18 @@ class TestTelegramChannel:
assert channel.config is config
assert channel._running is False
async def test_start_raises_without_token(self):
def test_start_raises_without_token(self):
config = TelegramConfig(bot_token="")
channel = TelegramChannel(config)
with pytest.raises(ChannelError, match="bot token"):
await channel.start()
_run(channel.start())
async def test_stop_when_not_running(self):
def test_stop_when_not_running(self):
config = TelegramConfig(bot_token="test")
channel = TelegramChannel(config)
await channel.stop()
_run(channel.stop())
async def test_send_returns_false_without_app(self):
def test_send_returns_false_without_app(self):
from EvoScientist.channels.base import OutboundMessage
config = TelegramConfig(bot_token="test")
@@ -53,5 +54,5 @@ class TestTelegramChannel:
content="hello",
metadata={"chat_id": "123"},
)
result = await channel.send(msg)
result = _run(channel.send(msg))
assert result is False
+9 -8
View File
@@ -4,6 +4,7 @@ from unittest.mock import MagicMock
from rich.table import Table
from tests.conftest import run_async as _run
from tests.fakes import FakeGraphGateway, FakeThreadStore
@@ -23,15 +24,15 @@ def _ctx(**overrides):
class TestThreadsCommand:
async def test_empty_list_prints_message(self):
def test_empty_list_prints_message(self):
from EvoScientist.commands.implementation.session import ThreadsCommand
ctx, ui = _ctx()
await ThreadsCommand().execute(ctx, [])
_run(ThreadsCommand().execute(ctx, []))
ui.append_system.assert_called_once()
assert "No saved sessions" in ui.append_system.call_args.args[0]
async def test_renders_table_with_current_marker(self):
def test_renders_table_with_current_marker(self):
from EvoScientist.commands.implementation.session import ThreadsCommand
ctx, ui = _ctx(thread_id="current")
@@ -53,7 +54,7 @@ class TestThreadsCommand:
]
store = FakeThreadStore(threads=threads)
ctx.graph_gateway = FakeGraphGateway(thread_store=store)
await ThreadsCommand().execute(ctx, [])
_run(ThreadsCommand().execute(ctx, []))
ui.mount_renderable.assert_called_once()
table = ui.mount_renderable.call_args.args[0]
assert isinstance(table, Table)
@@ -63,7 +64,7 @@ class TestThreadsCommand:
assert "/delete" in footer
assert "/new" in footer
async def test_footer_hint_suppressed_in_channel_mode(self):
def test_footer_hint_suppressed_in_channel_mode(self):
"""Channels don't get the footer — keeps outbound text short."""
from EvoScientist.commands.implementation.session import ThreadsCommand
@@ -79,10 +80,10 @@ class TestThreadsCommand:
]
store = FakeThreadStore(threads=threads)
ctx.graph_gateway = FakeGraphGateway(thread_store=store)
await ThreadsCommand().execute(ctx, [])
_run(ThreadsCommand().execute(ctx, []))
ui.append_system.assert_not_called()
async def test_channel_mode_drops_model_column(self):
def test_channel_mode_drops_model_column(self):
"""Non-interactive (channel) UIs get a narrower table."""
from EvoScientist.commands.implementation.session import ThreadsCommand
@@ -98,7 +99,7 @@ class TestThreadsCommand:
]
store = FakeThreadStore(threads=threads)
ctx.graph_gateway = FakeGraphGateway(thread_store=store)
await ThreadsCommand().execute(ctx, [])
_run(ThreadsCommand().execute(ctx, []))
# Channel mode: no Model column. 4 columns: ID, Preview, Msgs, Last Used.
table = ui.mount_renderable.call_args.args[0]
column_headers = [col.header for col in table.columns]
+16 -10
View File
@@ -149,34 +149,40 @@ class TestWrapToolCallAsync:
def setup_method(self):
self.mw = ToolErrorHandlerMiddleware()
async def test_success_passes_through(self):
@staticmethod
def _run(coro):
from tests.conftest import run_async
return run_async(coro)
def test_success_passes_through(self):
expected = ToolMessage(content="ok", tool_call_id="tc_001", name="t")
async def handler(req):
return expected
req = _make_request()
result = await self.mw.awrap_tool_call(req, handler)
result = self._run(self.mw.awrap_tool_call(req, handler))
assert result is expected
async def test_command_passes_through(self):
def test_command_passes_through(self):
cmd = Command(update={"messages": []})
async def handler(req):
return cmd
req = _make_request()
result = await self.mw.awrap_tool_call(req, handler)
result = self._run(self.mw.awrap_tool_call(req, handler))
assert result is cmd
async def test_exception_returns_error_tool_message(self):
def test_exception_returns_error_tool_message(self):
async def handler(req):
raise RuntimeError("MCP server timed out")
req = _make_request("slow_tool", "tc_async")
result = await self.mw.awrap_tool_call(req, handler)
result = self._run(self.mw.awrap_tool_call(req, handler))
assert isinstance(result, ToolMessage)
assert result.status == "error"
@@ -184,21 +190,21 @@ class TestWrapToolCallAsync:
assert result.name == "slow_tool"
assert "MCP server timed out" in result.content
async def test_exception_does_not_propagate(self):
def test_exception_does_not_propagate(self):
async def handler(req):
raise ConnectionError("connection lost")
req = _make_request()
result = await self.mw.awrap_tool_call(req, handler)
result = self._run(self.mw.awrap_tool_call(req, handler))
assert isinstance(result, ToolMessage)
async def test_keyboard_interrupt_propagates(self):
def test_keyboard_interrupt_propagates(self):
async def handler(req):
raise KeyboardInterrupt()
req = _make_request()
with pytest.raises(KeyboardInterrupt):
await self.mw.awrap_tool_call(req, handler)
self._run(self.mw.awrap_tool_call(req, handler))
# ---------------------------------------------------------------------------
+190 -162
View File
@@ -117,8 +117,8 @@ def _capture_app(monkeypatch) -> object:
# ---------------------------------------------------------------------------
async def test_clear_chat_resets_scroll_after_long_anchored_conversation(
monkeypatch,
def test_clear_chat_resets_scroll_after_long_anchored_conversation(
monkeypatch, run_async
):
"""Repro of issue #301: clear after a long anchored stream → banner on top.
@@ -128,91 +128,104 @@ async def test_clear_chat_resets_scroll_after_long_anchored_conversation(
the now-empty bottom of the previous content.
"""
from textual.containers import VerticalScroll
from textual.widgets import Static
async def scenario():
from textual.containers import VerticalScroll
from textual.widgets import Static
app = _capture_app(monkeypatch)
async with app.run_test(size=(80, 24)) as pilot:
await pilot.pause()
chat = app.query_one("#chat", VerticalScroll)
welcome = app.query_one("#welcome", Static)
app = _capture_app(monkeypatch)
async with app.run_test(size=(80, 24)) as pilot:
await pilot.pause()
chat = app.query_one("#chat", VerticalScroll)
welcome = app.query_one("#welcome", Static)
for i in range(80):
await chat.mount(Static(f"prior message {i}\n" * 2))
await pilot.pause()
chat.scroll_end(animate=False)
await pilot.pause()
chat.anchor()
await pilot.pause()
for i in range(80):
await chat.mount(Static(f"prior message {i}\n" * 2))
await pilot.pause()
chat.scroll_end(animate=False)
await pilot.pause()
chat.anchor()
await pilot.pause()
assert chat.scroll_y > 0, "precondition: viewport must be scrolled"
assert chat.scroll_y > 0, "precondition: viewport must be scrolled"
app.clear_chat()
app._append_system("New session: tid", style="green")
await pilot.pause()
await pilot.pause()
app.clear_chat()
app._append_system("New session: tid", style="green")
await pilot.pause()
await pilot.pause()
_assert_banner_at_top(chat, welcome, label="after /new on long anchored convo")
assert len(chat.children) == 2
_assert_banner_at_top(
chat, welcome, label="after /new on long anchored convo"
)
assert len(chat.children) == 2
run_async(scenario())
async def test_clear_chat_with_anchor_released_also_resets(monkeypatch):
def test_clear_chat_with_anchor_released_also_resets(monkeypatch, run_async):
"""User scrolled up (anchor released) before /new → still lands at top."""
from textual.containers import VerticalScroll
from textual.widgets import Static
async def scenario():
from textual.containers import VerticalScroll
from textual.widgets import Static
app = _capture_app(monkeypatch)
async with app.run_test(size=(80, 24)) as pilot:
await pilot.pause()
chat = app.query_one("#chat", VerticalScroll)
welcome = app.query_one("#welcome", Static)
app = _capture_app(monkeypatch)
async with app.run_test(size=(80, 24)) as pilot:
await pilot.pause()
chat = app.query_one("#chat", VerticalScroll)
welcome = app.query_one("#welcome", Static)
for i in range(80):
await chat.mount(Static(f"msg {i}\n" * 2))
await pilot.pause()
chat.anchor()
chat.scroll_to(y=80, animate=False)
await pilot.pause()
for i in range(80):
await chat.mount(Static(f"msg {i}\n" * 2))
await pilot.pause()
chat.anchor()
chat.scroll_to(y=80, animate=False)
await pilot.pause()
app.clear_chat()
app._append_system("New session: tid", style="green")
await pilot.pause()
await pilot.pause()
app.clear_chat()
app._append_system("New session: tid", style="green")
await pilot.pause()
await pilot.pause()
_assert_banner_at_top(chat, welcome, label="after /new with released anchor")
assert len(chat.children) == 2
_assert_banner_at_top(
chat, welcome, label="after /new with released anchor"
)
assert len(chat.children) == 2
run_async(scenario())
async def test_clear_chat_short_conversation_anchored(monkeypatch):
def test_clear_chat_short_conversation_anchored(monkeypatch, run_async):
"""Even with a short conversation, anchor + clear should not push banner down."""
from textual.containers import VerticalScroll
from textual.widgets import Static
async def scenario():
from textual.containers import VerticalScroll
from textual.widgets import Static
app = _capture_app(monkeypatch)
async with app.run_test(size=(80, 24)) as pilot:
await pilot.pause()
chat = app.query_one("#chat", VerticalScroll)
welcome = app.query_one("#welcome", Static)
app = _capture_app(monkeypatch)
async with app.run_test(size=(80, 24)) as pilot:
await pilot.pause()
chat = app.query_one("#chat", VerticalScroll)
welcome = app.query_one("#welcome", Static)
# Just enough content to overflow the viewport.
for i in range(30):
await chat.mount(Static(f"short msg {i}\n" * 2))
await pilot.pause()
chat.scroll_end(animate=False)
chat.anchor()
await pilot.pause()
# Just enough content to overflow the viewport.
for i in range(30):
await chat.mount(Static(f"short msg {i}\n" * 2))
await pilot.pause()
chat.scroll_end(animate=False)
chat.anchor()
await pilot.pause()
app.clear_chat()
app._append_system("New session: tid", style="green")
await pilot.pause()
await pilot.pause()
app.clear_chat()
app._append_system("New session: tid", style="green")
await pilot.pause()
await pilot.pause()
_assert_banner_at_top(chat, welcome, label="after /new on short convo")
_assert_banner_at_top(chat, welcome, label="after /new on short convo")
run_async(scenario())
async def test_clear_chat_then_full_user_turn_keeps_banner_at_top(monkeypatch):
def test_clear_chat_then_full_user_turn_keeps_banner_at_top(monkeypatch, run_async):
"""Repro of the user-reported scenario: clear → mount welcome banner →
mount new-session → mount user message → mount assistant reply, in a
normal-sized terminal where the resulting content fits in the viewport.
@@ -224,64 +237,69 @@ async def test_clear_chat_then_full_user_turn_keeps_banner_at_top(monkeypatch):
wipe even when more widgets are mounted afterwards.
"""
from textual.containers import VerticalScroll
from textual.widgets import Static
async def scenario():
from textual.containers import VerticalScroll
from textual.widgets import Static
app = _capture_app(monkeypatch)
# Tall-ish terminal: welcome + a few messages must fit in the
# viewport, mirroring the user's manual-test setup.
async with app.run_test(size=(80, 40)) as pilot:
await pilot.pause()
chat = app.query_one("#chat", VerticalScroll)
welcome = app.query_one("#welcome", Static)
app = _capture_app(monkeypatch)
# Tall-ish terminal: welcome + a few messages must fit in the
# viewport, mirroring the user's manual-test setup.
async with app.run_test(size=(80, 40)) as pilot:
await pilot.pause()
chat = app.query_one("#chat", VerticalScroll)
welcome = app.query_one("#welcome", Static)
# Long conversation, then /new.
for i in range(80):
await chat.mount(Static(f"prior message {i}\n" * 2))
await pilot.pause()
chat.scroll_end(animate=False)
chat.anchor()
await pilot.pause()
# Long conversation, then /new.
for i in range(80):
await chat.mount(Static(f"prior message {i}\n" * 2))
await pilot.pause()
chat.scroll_end(animate=False)
chat.anchor()
await pilot.pause()
app.clear_chat()
# Render the actual banner (not the empty placeholder) and add
# the /new system message — this is exactly what
# ``start_new_session`` does after clearing.
app._render_welcome()
app._append_system("New session: tid", style="green")
await pilot.pause()
await pilot.pause()
app.clear_chat()
# Render the actual banner (not the empty placeholder) and add
# the /new system message — this is exactly what
# ``start_new_session`` does after clearing.
app._render_welcome()
app._append_system("New session: tid", style="green")
await pilot.pause()
await pilot.pause()
# User types "hello" — _run_turn mounts UserMessage then calls
# ``container.scroll_end(animate=False)`` (line 1305 in the
# real code). In a tall viewport this still lands at scroll_y
# == 0 because content fits.
from EvoScientist.cli.widgets.assistant_message import AssistantMessage
from EvoScientist.cli.widgets.user_message import UserMessage
# User types "hello" — _run_turn mounts UserMessage then calls
# ``container.scroll_end(animate=False)`` (line 1305 in the
# real code). In a tall viewport this still lands at scroll_y
# == 0 because content fits.
from EvoScientist.cli.widgets.assistant_message import AssistantMessage
from EvoScientist.cli.widgets.user_message import UserMessage
await chat.mount(UserMessage("hello"))
chat.scroll_end(animate=False)
await pilot.pause()
await chat.mount(UserMessage("hello"))
chat.scroll_end(animate=False)
await pilot.pause()
await chat.mount(
AssistantMessage("Hello. What research problem are we working on today?")
)
await pilot.pause()
await pilot.pause()
await chat.mount(
AssistantMessage(
"Hello. What research problem are we working on today?"
)
)
await pilot.pause()
await pilot.pause()
_assert_banner_at_top(
chat,
welcome,
label=(
f"after full /new → user msg → reply "
f"(max={chat.max_scroll_y}, "
f"viewport={chat.scrollable_content_region.height}, "
f"content={chat.content_size.height})"
),
)
_assert_banner_at_top(
chat,
welcome,
label=(
f"after full /new → user msg → reply "
f"(max={chat.max_scroll_y}, "
f"viewport={chat.scrollable_content_region.height}, "
f"content={chat.content_size.height})"
),
)
run_async(scenario())
async def test_short_turn_keeps_banner_at_top_after_layout_refresh(monkeypatch):
def test_short_turn_keeps_banner_at_top_after_layout_refresh(monkeypatch, run_async):
"""Regression for the second symptom of issue #301: after a short
user/assistant turn that fits in the viewport, end-of-stream
``_anchor_chat`` must NOT leave the chat anchored.
@@ -295,73 +313,83 @@ async def test_short_turn_keeps_banner_at_top_after_layout_refresh(monkeypatch):
overflows (``max_scroll_y > 0``).
"""
from textual.containers import VerticalScroll
from textual.widgets import Static
async def scenario():
from textual.containers import VerticalScroll
from textual.widgets import Static
from EvoScientist.cli.widgets.assistant_message import AssistantMessage
from EvoScientist.cli.widgets.user_message import UserMessage
from EvoScientist.cli.widgets.assistant_message import AssistantMessage
from EvoScientist.cli.widgets.user_message import UserMessage
app = _capture_app(monkeypatch)
# Tall terminal: welcome + a short exchange fits with room to spare,
# which is exactly the bug condition (content < viewport).
async with app.run_test(size=(80, 40)) as pilot:
await pilot.pause()
chat = app.query_one("#chat", VerticalScroll)
welcome = app.query_one("#welcome", Static)
app = _capture_app(monkeypatch)
# Tall terminal: welcome + a short exchange fits with room to spare,
# which is exactly the bug condition (content < viewport).
async with app.run_test(size=(80, 40)) as pilot:
await pilot.pause()
chat = app.query_one("#chat", VerticalScroll)
welcome = app.query_one("#welcome", Static)
await chat.mount(UserMessage("hi"))
await pilot.pause()
await chat.mount(AssistantMessage("Hi. What are you looking to work on today?"))
await pilot.pause()
await chat.mount(UserMessage("hi"))
await pilot.pause()
await chat.mount(
AssistantMessage("Hi. What are you looking to work on today?")
)
await pilot.pause()
# End-of-stream re-anchor (matches _stream_with_widgets).
app._anchor_chat(chat)
await pilot.pause()
# End-of-stream re-anchor (matches _stream_with_widgets).
app._anchor_chat(chat)
await pilot.pause()
# Any subsequent mount triggers a layout refresh — this is when
# the compositor would push scroll_y negative without the fix.
# In production this happens via Markdown re-renders, status-bar
# updates, the system "usage" line, etc.
await chat.mount(Static("trailing line\n"))
await pilot.pause()
await pilot.pause()
# Any subsequent mount triggers a layout refresh — this is when
# the compositor would push scroll_y negative without the fix.
# In production this happens via Markdown re-renders, status-bar
# updates, the system "usage" line, etc.
await chat.mount(Static("trailing line\n"))
await pilot.pause()
await pilot.pause()
_assert_banner_at_top(chat, welcome, label="after short turn + trailing mount")
_assert_banner_at_top(
chat, welcome, label="after short turn + trailing mount"
)
run_async(scenario())
async def test_long_turn_keeps_viewport_pinned_to_bottom(monkeypatch):
def test_long_turn_keeps_viewport_pinned_to_bottom(monkeypatch, run_async):
"""When the conversation overflows, ``_anchor_chat`` must still engage
the anchor so streaming output remains visible. The issue #301 fix
only suppresses anchoring when content fits — long content must
continue to behave as before.
"""
from textual.containers import VerticalScroll
from textual.widgets import Static
async def scenario():
from textual.containers import VerticalScroll
from textual.widgets import Static
app = _capture_app(monkeypatch)
async with app.run_test(size=(80, 24)) as pilot:
await pilot.pause()
chat = app.query_one("#chat", VerticalScroll)
app = _capture_app(monkeypatch)
async with app.run_test(size=(80, 24)) as pilot:
await pilot.pause()
chat = app.query_one("#chat", VerticalScroll)
for i in range(50):
await chat.mount(Static(f"prior message {i}\n" * 2))
await pilot.pause()
for i in range(50):
await chat.mount(Static(f"prior message {i}\n" * 2))
await pilot.pause()
app._anchor_chat(chat)
await pilot.pause()
assert chat.scroll_y == chat.max_scroll_y, (
"long content must anchor to bottom after _anchor_chat"
)
app._anchor_chat(chat)
await pilot.pause()
assert chat.scroll_y == chat.max_scroll_y, (
"long content must anchor to bottom after _anchor_chat"
)
# Trailing mount must keep the viewport pinned to the new bottom.
await chat.mount(Static("trailing line\n"))
await pilot.pause()
await pilot.pause()
assert chat.scroll_y == chat.max_scroll_y, (
"anchored viewport must follow new bottom after trailing mount"
)
assert chat.scroll_y > 0, "long content must have positive scroll_y"
# Trailing mount must keep the viewport pinned to the new bottom.
await chat.mount(Static("trailing line\n"))
await pilot.pause()
await pilot.pause()
assert chat.scroll_y == chat.max_scroll_y, (
"anchored viewport must follow new bottom after trailing mount"
)
assert chat.scroll_y > 0, "long content must have positive scroll_y"
run_async(scenario())
# ---------------------------------------------------------------------------
+7 -8
View File
@@ -5,6 +5,7 @@ from types import SimpleNamespace
import pytest
from EvoScientist.commands.base import ChannelRuntime, CommandContext
from tests.conftest import run_async as _run
pytest.importorskip("textual")
@@ -42,7 +43,7 @@ class _StubApp:
self.refresh_calls.append(reset_streaming_text)
async def test_sync_tui_command_completion_adopts_agent_swap(monkeypatch):
def test_sync_tui_command_completion_adopts_agent_swap(monkeypatch):
import EvoScientist.cli.tui_interactive as tui_mod
from EvoScientist import EvoScientist as evosci_mod
@@ -62,7 +63,7 @@ async def test_sync_tui_command_completion_adopts_agent_swap(monkeypatch):
monkeypatch.setattr(tui_mod, "_channels_is_running", lambda: True)
app._channel_runtime.bind("old-agent", "old-thread")
await tui_mod._sync_tui_command_completion(app, ctx, "old-agent", cmd)
_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")]
@@ -71,7 +72,7 @@ async def test_sync_tui_command_completion_adopts_agent_swap(monkeypatch):
assert app._channel_runtime.thread_id == "thread-1"
async def test_sync_tui_command_completion_refreshes_without_agent_swap(monkeypatch):
def test_sync_tui_command_completion_refreshes_without_agent_swap(monkeypatch):
import EvoScientist.cli.tui_interactive as tui_mod
app = _StubApp()
@@ -84,16 +85,14 @@ async def test_sync_tui_command_completion_refreshes_without_agent_swap(monkeypa
monkeypatch.setattr(tui_mod, "_channels_is_running", lambda: False)
await tui_mod._sync_tui_command_completion(app, ctx, "same-agent", cmd)
_run(tui_mod._sync_tui_command_completion(app, ctx, "same-agent", cmd))
assert app._agent_loader.adopt_calls == []
assert app.model_updates == []
assert app.refresh_calls == [True]
async def test_sync_tui_rebinds_runtime_on_thread_rotation_without_agent_swap(
monkeypatch,
):
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."""
@@ -112,7 +111,7 @@ async def test_sync_tui_rebinds_runtime_on_thread_rotation_without_agent_swap(
monkeypatch.setattr(tui_mod, "_channels_is_running", lambda: True)
await tui_mod._sync_tui_command_completion(app, ctx, "same-agent", cmd)
_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"
+6 -6
View File
@@ -10,16 +10,14 @@ import importlib
import unittest
from unittest.mock import AsyncMock
import pytest
# ---------------------------------------------------------------------------
# Textual might not be installed — skip entire module if missing
# ---------------------------------------------------------------------------
_has_textual = importlib.util.find_spec("textual") is not None
@pytest.mark.skipif(not _has_textual, reason="textual not installed")
class TestLoadingWidget:
@unittest.skipUnless(_has_textual, "textual not installed")
class TestLoadingWidget(unittest.TestCase):
"""LoadingWidget construction and attributes."""
def test_construction(self):
@@ -45,7 +43,7 @@ class TestLoadingWidget:
assert w._frame == 1
assert w._elapsed == 0.1
async def test_cleanup_stops_timer_and_removes(self):
def test_cleanup_stops_timer_and_removes(self):
from EvoScientist.cli.widgets.loading_widget import LoadingWidget
class _Timer:
@@ -60,7 +58,9 @@ class TestLoadingWidget:
w._timer_handle = timer
w.remove = AsyncMock()
await w.cleanup()
from tests.conftest import run_async
run_async(w.cleanup())
assert timer.stopped is True
assert w._timer_handle is None
+15
View File
@@ -0,0 +1,15 @@
from __future__ import annotations
from EvoScientist.deploy.webui import _resolve_webui_package
def test_resolve_webui_package_defaults_to_published_package(monkeypatch):
monkeypatch.delenv("EVOSCIENTIST_WEBUI_PACKAGE", raising=False)
assert _resolve_webui_package() == "@evoscientist/webui@latest"
def test_resolve_webui_package_allows_local_override(monkeypatch):
monkeypatch.setenv("EVOSCIENTIST_WEBUI_PACKAGE", "/tmp/EvoScientist-WebUI")
assert _resolve_webui_package() == "/tmp/EvoScientist-WebUI"
+131 -109
View File
@@ -20,6 +20,7 @@ from EvoScientist.channels.wechat.crypto import (
_pkcs7_unpad,
parse_xml,
)
from tests.conftest import run_async as _run
# ── Config tests ──────────────────────────────────────────────────
@@ -82,36 +83,36 @@ class TestWeChatChannelInit:
channel = WeChatChannel(config, backend="wechatmp")
assert channel._backend == "wechatmp"
async def test_start_raises_without_corp_id(self):
def test_start_raises_without_corp_id(self):
config = WeComConfig(corp_id="", agent_id="1", secret="s")
channel = WeChatChannel(config, backend="wecom")
with pytest.raises(ChannelError, match="corp_id"):
await channel.start()
_run(channel.start())
async def test_start_raises_without_secret(self):
def test_start_raises_without_secret(self):
config = WeComConfig(corp_id="corp", agent_id="1", secret="")
channel = WeChatChannel(config, backend="wecom")
with pytest.raises(ChannelError, match="secret"):
await channel.start()
_run(channel.start())
async def test_start_raises_without_agent_id(self):
def test_start_raises_without_agent_id(self):
config = WeComConfig(corp_id="corp", agent_id="", secret="s")
channel = WeChatChannel(config, backend="wecom")
with pytest.raises(ChannelError, match="agent_id"):
await channel.start()
_run(channel.start())
async def test_start_raises_mp_without_app_id(self):
def test_start_raises_mp_without_app_id(self):
config = WeChatMPConfig(app_id="", app_secret="s")
channel = WeChatChannel(config, backend="wechatmp")
with pytest.raises(ChannelError, match="app_id"):
await channel.start()
_run(channel.start())
async def test_stop_when_not_running(self):
def test_stop_when_not_running(self):
config = WeComConfig(corp_id="c", agent_id="1", secret="s")
channel = WeChatChannel(config, backend="wecom")
await channel.stop() # Should not raise
_run(channel.stop()) # Should not raise
async def test_send_returns_false_without_client(self):
def test_send_returns_false_without_client(self):
from EvoScientist.channels.base import OutboundMessage
config = WeComConfig(corp_id="c", agent_id="1", secret="s")
@@ -122,7 +123,7 @@ class TestWeChatChannelInit:
content="hello",
metadata={"chat_id": "user1"},
)
result = await channel.send(msg)
result = _run(channel.send(msg))
assert result is False
@@ -323,123 +324,144 @@ class TestMessageProcessing:
)
return WeChatChannel(config, backend="wecom")
async def test_text_message_queued(self):
def test_text_message_queued(self):
channel = self._make_channel()
await channel._process_message(
{
"MsgType": "text",
"Content": "Hello!",
"FromUserName": "user1",
"ToUserName": "bot",
"MsgId": "100",
"CreateTime": str(int(time.time())),
}
)
# Check message was enqueued
assert not channel._queue.empty()
msg = await asyncio.wait_for(channel._queue.get(), timeout=1.0)
assert msg.content == "Hello!"
assert msg.sender_id == "user1"
assert msg.channel == "wechat"
async def _test():
await channel._process_message(
{
"MsgType": "text",
"Content": "Hello!",
"FromUserName": "user1",
"ToUserName": "bot",
"MsgId": "100",
"CreateTime": str(int(time.time())),
}
)
# Check message was enqueued
assert not channel._queue.empty()
msg = await asyncio.wait_for(channel._queue.get(), timeout=1.0)
assert msg.content == "Hello!"
assert msg.sender_id == "user1"
assert msg.channel == "wechat"
async def test_location_message(self):
_run(_test())
def test_location_message(self):
channel = self._make_channel()
await channel._process_message(
{
"MsgType": "location",
"Location_X": "39.9",
"Location_Y": "116.4",
"Label": "Beijing",
"FromUserName": "user1",
"ToUserName": "bot",
"MsgId": "101",
"CreateTime": str(int(time.time())),
}
)
msg = await asyncio.wait_for(channel._queue.get(), timeout=1.0)
assert "Beijing" in msg.content
assert "39.9" in msg.content
async def _test():
await channel._process_message(
{
"MsgType": "location",
"Location_X": "39.9",
"Location_Y": "116.4",
"Label": "Beijing",
"FromUserName": "user1",
"ToUserName": "bot",
"MsgId": "101",
"CreateTime": str(int(time.time())),
}
)
msg = await asyncio.wait_for(channel._queue.get(), timeout=1.0)
assert "Beijing" in msg.content
assert "39.9" in msg.content
async def test_voice_recognition(self):
_run(_test())
def test_voice_recognition(self):
channel = self._make_channel()
await channel._process_message(
{
"MsgType": "voice",
"Recognition": "你好世界",
"FromUserName": "user1",
"ToUserName": "bot",
"MsgId": "102",
"CreateTime": str(int(time.time())),
}
)
msg = await asyncio.wait_for(channel._queue.get(), timeout=1.0)
assert "你好世界" in msg.content
async def _test():
await channel._process_message(
{
"MsgType": "voice",
"Recognition": "你好世界",
"FromUserName": "user1",
"ToUserName": "bot",
"MsgId": "102",
"CreateTime": str(int(time.time())),
}
)
msg = await asyncio.wait_for(channel._queue.get(), timeout=1.0)
assert "你好世界" in msg.content
async def test_link_message(self):
_run(_test())
def test_link_message(self):
channel = self._make_channel()
await channel._process_message(
{
"MsgType": "link",
"Title": "Test Link",
"Description": "A description",
"Url": "https://example.com",
"FromUserName": "user1",
"ToUserName": "bot",
"MsgId": "103",
"CreateTime": str(int(time.time())),
}
)
msg = await asyncio.wait_for(channel._queue.get(), timeout=1.0)
assert "Test Link" in msg.content
assert "https://example.com" in msg.content
async def _test():
await channel._process_message(
{
"MsgType": "link",
"Title": "Test Link",
"Description": "A description",
"Url": "https://example.com",
"FromUserName": "user1",
"ToUserName": "bot",
"MsgId": "103",
"CreateTime": str(int(time.time())),
}
)
msg = await asyncio.wait_for(channel._queue.get(), timeout=1.0)
assert "Test Link" in msg.content
assert "https://example.com" in msg.content
async def test_subscribe_event(self):
_run(_test())
def test_subscribe_event(self):
channel = self._make_channel()
await channel._process_message(
{
"MsgType": "event",
"Event": "subscribe",
"FromUserName": "user1",
"ToUserName": "bot",
"MsgId": "",
"CreateTime": str(int(time.time())),
}
)
msg = await asyncio.wait_for(channel._queue.get(), timeout=1.0)
assert "关注" in msg.content
async def _test():
await channel._process_message(
{
"MsgType": "event",
"Event": "subscribe",
"FromUserName": "user1",
"ToUserName": "bot",
"MsgId": "",
"CreateTime": str(int(time.time())),
}
)
msg = await asyncio.wait_for(channel._queue.get(), timeout=1.0)
assert "关注" in msg.content
async def test_unsubscribe_ignored(self):
_run(_test())
def test_unsubscribe_ignored(self):
channel = self._make_channel()
await channel._process_message(
{
"MsgType": "event",
"Event": "unsubscribe",
"FromUserName": "user1",
"ToUserName": "bot",
"MsgId": "",
"CreateTime": str(int(time.time())),
}
)
assert channel._queue.empty()
async def _test():
await channel._process_message(
{
"MsgType": "event",
"Event": "unsubscribe",
"FromUserName": "user1",
"ToUserName": "bot",
"MsgId": "",
"CreateTime": str(int(time.time())),
}
)
assert channel._queue.empty()
async def test_empty_message_ignored(self):
_run(_test())
def test_empty_message_ignored(self):
channel = self._make_channel()
await channel._process_message(
{
"MsgType": "text",
"Content": "",
"FromUserName": "",
"ToUserName": "bot",
}
)
assert channel._queue.empty()
async def _test():
await channel._process_message(
{
"MsgType": "text",
"Content": "",
"FromUserName": "",
"ToUserName": "bot",
}
)
assert channel._queue.empty()
_run(_test())
# ── Registration test ─────────────────────────────────────────────
Generated
+3 -20
View File
@@ -992,7 +992,6 @@ dev = [
{ name = "build" },
{ name = "pre-commit" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "pytest-timeout" },
{ name = "ruff" },
@@ -1033,7 +1032,6 @@ dev = [
{ name = "build" },
{ name = "pre-commit" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "pytest-timeout" },
{ name = "ruff" },
@@ -1077,7 +1075,6 @@ requires-dist = [
{ name = "pycryptodome", marker = "extra == 'all-channels'", specifier = ">=3.20" },
{ name = "pycryptodome", marker = "extra == 'wechat'", specifier = ">=3.20" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.0" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" },
{ name = "pytest-timeout", marker = "extra == 'dev'", specifier = ">=2.4" },
{ name = "python-dotenv", specifier = ">=1.0" },
@@ -1106,7 +1103,6 @@ dev = [
{ name = "build", specifier = ">=1.0" },
{ name = "pre-commit", specifier = ">=3.5.0" },
{ name = "pytest", specifier = ">=8.0" },
{ name = "pytest-asyncio", specifier = ">=1.0" },
{ name = "pytest-cov", specifier = ">=5.0" },
{ name = "pytest-timeout", specifier = ">=2.4" },
{ name = "ruff", specifier = ">=0.5" },
@@ -3507,19 +3503,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
]
[[package]]
name = "pytest-cov"
version = "7.1.0"
@@ -4195,11 +4178,11 @@ wheels = [
[[package]]
name = "soupsieve"
version = "2.8.4"
version = "2.8.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" },
{ url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" },
]
[[package]]