Merge remote-tracking branch 'upstream/main'

# Conflicts:
#	README.md
#	uv.lock
This commit is contained in:
m4
2026-07-13 09:46:07 +08:00
78 changed files with 3718 additions and 3766 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.1</text>
font-size="13" font-weight="700" fill="#ffffff">v0.2.2</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.1</text>
font-size="13" font-weight="700" fill="#ffffff">v0.2.2</text>
</svg>

Before

Width:  |  Height:  |  Size: 555 B

After

Width:  |  Height:  |  Size: 555 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

After

Width:  |  Height:  |  Size: 287 KiB

+10 -5
View File
@@ -75,11 +75,13 @@ 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 ──────────────────────────────────────────────────
@@ -93,15 +95,16 @@ class DedupCache:
if not msg_id:
return False
self._prune()
now = self._clock()
self._prune(now)
if msg_id in self._seen:
# LRU: refresh position and timestamp
self._seen.move_to_end(msg_id)
self._seen[msg_id] = time.monotonic()
self._seen[msg_id] = now
return True
self._seen[msg_id] = time.monotonic()
self._seen[msg_id] = now
if len(self._seen) > self._max:
while len(self._seen) > self._trim:
self._seen.popitem(last=False)
@@ -118,9 +121,9 @@ class DedupCache:
# ── internal ────────────────────────────────────────────────────
def _prune(self) -> None:
def _prune(self, now: float | None = None) -> None:
"""Remove entries older than *ttl_seconds*."""
cutoff = time.monotonic() - self._ttl
cutoff = (self._clock() if now is None else now) - self._ttl
# OrderedDict is insertion-ordered; oldest entries are first.
while self._seen:
_key, ts = next(iter(self._seen.items()))
@@ -428,11 +431,13 @@ 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(
+66 -109
View File
@@ -371,14 +371,31 @@ def _step_minimax_region(config: EvoScientistConfig) -> str:
return _MINIMAX_REGIONS[region]
def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str:
"""Step 2a: Select Anthropic authentication mode (API key vs OAuth).
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.
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", "oauth", or "auto".
Selected auth mode: "api_key" or "oauth".
"""
from ...ccproxy_manager import check_ccproxy_auth, is_ccproxy_available
@@ -386,10 +403,14 @@ def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str:
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="API Key (direct Anthropic access)", value="api_key"),
Choice(title=f"API Key (direct {provider_label} access)", value="api_key"),
Choice(
title="Claude Code OAuth (via ccproxy — no API key needed)"
title=f"{oauth_label} (via ccproxy — no API key needed)"
+ (
""
if ccproxy_available
@@ -401,12 +422,12 @@ def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str:
Choice(title="← Back (re-pick provider)", value=BACK_SENTINEL),
]
current = config.anthropic_auth_mode
current = getattr(config, config_attr)
if current not in ("api_key", "oauth"):
current = "api_key"
question = questionary.select(
"Authentication mode [Esc/← to go back]:",
f"{auth_question_label} [Esc/← to go back]:",
choices=choices,
default=current,
style=WIZARD_STYLE,
@@ -448,11 +469,9 @@ def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str:
if auth_mode == "oauth":
_prompt_ccproxy_port(config)
# If OAuth selected, check auth status and offer login
if auth_mode in ("oauth", "auto"):
authed, msg = check_ccproxy_auth()
authed, msg = check_ccproxy_auth(ccproxy_provider)
if authed:
console.print(f" [green]✓ OAuth: {msg}[/green]")
console.print(f" [green]✓ {auth_status_label}: {msg}[/green]")
relogin = questionary.confirm(
"Re-authenticate to refresh credentials?",
default=False,
@@ -462,11 +481,13 @@ def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str:
if relogin is None:
raise KeyboardInterrupt()
if relogin:
_run_ccproxy_login("claude_api", "OAuth")
_run_ccproxy_login(ccproxy_provider, auth_status_label)
else:
console.print(f" [yellow]OAuth not authenticated: {msg}[/yellow]")
console.print(
f" [yellow]{auth_status_label} not authenticated: {msg}[/yellow]"
)
login = questionary.confirm(
"Log in to Claude now?",
f"Log in to {prompt_login_label} now?",
default=True,
style=CONFIRM_STYLE,
qmark=QMARK,
@@ -474,11 +495,32 @@ def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str:
if login is None:
raise KeyboardInterrupt()
if login:
_run_ccproxy_login("claude_api", "OAuth")
_run_ccproxy_login(ccproxy_provider, auth_status_label)
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).
@@ -488,101 +530,16 @@ def _step_openai_auth_mode(config: EvoScientistConfig) -> str:
Returns:
Selected auth mode: "api_key" or "oauth".
"""
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,
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",
)
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(
+258 -181
View File
@@ -129,6 +129,12 @@ _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.
@@ -142,6 +148,201 @@ 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"),
@@ -479,102 +680,17 @@ def run_onboard(
provider = _step_provider(config)
config.provider = provider
# 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"
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"
),
)
except GoBack:
# User picked "← Back" — restore config to its state at the
# top of this iteration (drops any base_url / region /
@@ -594,60 +710,9 @@ def run_onboard(
ollama_detected_models = []
console.print(" [dim]↩ Returning to provider selection.[/dim]")
continue
break # auth_mode succeeded — exit sub-loop
break # Provider setup succeeded — exit sub-loop
# 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")
_reconcile_oauth_modes(config)
_autosave(config)
else:
# Provider section skipped — keep prior provider value to drive
@@ -680,44 +745,55 @@ def run_onboard(
"kept current" if config.auxiliary_model else "not set",
)
elif _step_auxiliary_enable(config):
# 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(
from .prompter import GoBack
aux_ollama_detected_models: list[str] = []
while True:
loop_snapshot = copy.deepcopy(config)
aux_provider = _step_provider(
config,
current_value=config.custom_openai_base_url
or os.environ.get("CUSTOM_OPENAI_BASE_URL", ""),
label="co-pilot",
default_value=config.auxiliary_provider,
)
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_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
config.auxiliary_model = _step_model(
config,
aux_provider,
ollama_detected_models=aux_ollama_detected_models,
label="co-pilot",
default_value=config.auxiliary_model,
)
@@ -725,6 +801,7 @@ 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:
+8
View File
@@ -548,6 +548,10 @@ 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)
@@ -560,6 +564,10 @@ def save_config(config: EvoScientistConfig) -> None:
sort_keys=False,
allow_unicode=True,
)
try:
config_path.chmod(0o600)
except OSError:
pass
def reset_config() -> None:
+11 -5
View File
@@ -306,14 +306,19 @@ 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
@@ -709,6 +714,7 @@ 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,
+6 -2
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.3: 1M).
# xAI Grok — per-model windows (build-0.1: 256K, 4.5: 500K).
"grok-build-0.1": 256_000,
"grok-4.3": 1_000_000,
"grok-4.5": 500_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,6 +32,8 @@ _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.
@@ -40,6 +42,8 @@ _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.
+8 -1
View File
@@ -89,6 +89,9 @@ _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"),
@@ -145,6 +148,9 @@ _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"),
@@ -159,7 +165,8 @@ _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.3", "x-ai/grok-4.3", "openrouter"),
("grok-4.5", "x-ai/grok-4.5", "openrouter"),
("hy3", "tencent/hy3", "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"),
+2 -4
View File
@@ -179,6 +179,7 @@ only a transport event, not proof that the LangGraph run has finished.
<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.
@@ -463,10 +464,7 @@ 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/stream-json.md](docs/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/guides/stream-json.md](docs/guides/stream-json.md) for the event schema.
</details>
+5
View File
@@ -11,6 +11,11 @@
|------------------------------------------------------------|---------------------------------------------------------------------------------|
| [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:
+4
View File
@@ -48,6 +48,7 @@ dependencies = [
[dependency-groups]
dev = [
"pytest>=8.0",
"pytest-asyncio>=1.0",
"pytest-cov>=5.0",
"pytest-timeout>=2.4",
"ruff>=0.5",
@@ -58,6 +59,7 @@ dev = [
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=1.0",
"pytest-cov>=5.0",
"pytest-timeout>=2.4",
"ruff>=0.5",
@@ -117,6 +119,8 @@ 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,36 +1,8 @@
"""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.
+10 -15
View File
@@ -9,7 +9,6 @@ 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]:
@@ -17,24 +16,20 @@ async def async_iter(items: Iterable[Any]) -> AsyncIterator[Any]:
yield item
def collect_events(
async def collect_events(
agent,
message: str = "hi",
thread_id: str = "t1",
):
"""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())
"""Collect stream_agent_events output for tests."""
events = []
async for ev in stream_agent_events(
agent,
message,
thread_id,
):
events.append(ev)
return events
def protocol_event(
+20 -19
View File
@@ -10,18 +10,17 @@ 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:
def test_start_raises_without_required_imap_settings(self):
async def test_start_raises_without_required_imap_settings(self):
channel = EmailChannel(EmailConfig())
with pytest.raises(
ChannelError, match="imap_host and imap_username are required"
):
_run(channel.start())
await channel.start()
def test_send_returns_false_when_smtp_not_ready(self):
async def test_send_returns_false_when_smtp_not_ready(self):
channel = EmailChannel(EmailConfig())
msg = OutboundMessage(
channel="email",
@@ -29,16 +28,16 @@ class TestEmailChannelSmoke:
content="hello",
metadata={"chat_id": "user@example.com"},
)
assert _run(channel.send(msg)) is False
assert await channel.send(msg) is False
class TestSignalChannelSmoke:
def test_start_raises_without_phone_number(self):
async def test_start_raises_without_phone_number(self):
channel = SignalChannel(SignalConfig())
with pytest.raises(ChannelError, match="phone_number is required"):
_run(channel.start())
await channel.start()
def test_send_returns_false_when_not_connected(self):
async def test_send_returns_false_when_not_connected(self):
channel = SignalChannel(SignalConfig(phone_number="+123456789"))
msg = OutboundMessage(
channel="signal",
@@ -46,27 +45,29 @@ class TestSignalChannelSmoke:
content="hello",
metadata={"chat_id": "+123456789"},
)
assert _run(channel.send(msg)) is False
assert await channel.send(msg) is False
class TestQQChannelSmoke:
def test_start_raises_when_sdk_missing(self, monkeypatch):
async 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"):
_run(channel.start())
await channel.start()
def test_start_raises_without_credentials_when_sdk_available(self, monkeypatch):
async 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"):
_run(channel.start())
await channel.start()
def test_send_returns_false_without_client(self):
async def test_send_returns_false_without_client(self):
channel = QQChannel(QQConfig(app_id="id", app_secret="secret"))
msg = OutboundMessage(
channel="qq",
@@ -74,11 +75,11 @@ class TestQQChannelSmoke:
content="hello",
metadata={"chat_id": "openid"},
)
assert _run(channel.send(msg)) is False
assert await channel.send(msg) is False
class TestIMessageChannelSmoke:
def test_start_wraps_rpc_bootstrap_error(self, monkeypatch):
async def test_start_wraps_rpc_bootstrap_error(self, monkeypatch):
async def _broken_start(self):
raise RuntimeError("imsg not found")
@@ -87,9 +88,9 @@ class TestIMessageChannelSmoke:
monkeypatch.setattr(imessage_module.ImsgRpcClient, "start", _broken_start)
channel = IMessageChannelRpc(IMessageConfig())
with pytest.raises(ChannelError, match="Failed to start imsg"):
_run(channel.start())
await channel.start()
def test_send_returns_false_without_rpc_client(self):
async def test_send_returns_false_without_rpc_client(self):
channel = IMessageChannelRpc(IMessageConfig())
msg = OutboundMessage(
channel="imessage",
@@ -97,4 +98,4 @@ class TestIMessageChannelSmoke:
content="hello",
metadata={"chat_id": "+123456789"},
)
assert _run(channel.send(msg)) is False
assert await channel.send(msg) is False
+123 -146
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import threading
import pytest
@@ -93,88 +94,95 @@ def _make_loader_fn(agent_value="AGENT", fail_with=None, capture=None):
return _loader
def _run(coro):
return asyncio.run(coro)
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)
class TestBackgroundAgentLoaderStart:
def test_start_creates_task_and_forwards_kwargs(self):
async def test_start_creates_task_and_forwards_kwargs(self):
captured: dict = {}
loader = BackgroundAgentLoader(_make_loader_fn(capture=captured))
async def _go():
loader.start(workspace_dir="/ws", checkpointer="CK")
assert loader.task is not None
assert loader.is_pending
await loader.await_ready()
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"}
def test_start_bumps_load_id(self):
async def test_start_bumps_load_id(self):
loader = BackgroundAgentLoader(_make_loader_fn())
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()
assert loader._load_id == 0
loader.start()
assert loader._load_id == 1
loader.start()
assert loader._load_id == 2
await loader.await_ready()
_run(_go())
async def test_start_cancels_in_flight_prior_task(self):
blocking = _GatedThreadLoader("LATE")
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())
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()
class TestBackgroundAgentLoaderCallbacks:
def test_progress_hook_sees_events_in_order(self):
async 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)),
)
async def _go():
loader.start()
await loader.await_ready()
loader.start()
await loader.await_ready()
_run(_go())
assert events == [("start", "srv", ""), ("success", "srv", "1")]
def test_stale_progress_events_are_dropped(self):
async def test_stale_progress_events_are_dropped(self):
"""A progress event fired after a newer `start` must not reach the hook."""
import time
slow_loader = _GatedThreadLoader(
"slow-agent", progress_events=[("success", "from-slow", "1")]
)
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")
@@ -184,36 +192,32 @@ class TestBackgroundAgentLoaderCallbacks:
slow_loader, on_progress=lambda e, s, d: seen.append(s)
)
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)
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)
_run(_go())
assert "from-fast" in seen
assert "from-slow" not in seen
def test_success_callback_fires_on_completion(self):
async def test_success_callback_fires_on_completion(self):
got = []
loader = BackgroundAgentLoader(
_make_loader_fn("MY_AGENT"),
on_success=lambda a: got.append(a),
)
async def _go():
loader.start()
await loader.await_ready()
await asyncio.sleep(0) # let done-callback run
loader.start()
await loader.await_ready()
await asyncio.sleep(0) # let done-callback run
_run(_go())
assert got == ["MY_AGENT"]
def test_failure_callback_fires_on_error(self):
async def test_failure_callback_fires_on_error(self):
err = RuntimeError("load failed")
got_failures = []
got_successes = []
@@ -223,40 +227,33 @@ class TestBackgroundAgentLoaderCallbacks:
on_failure=lambda e: got_failures.append(e),
)
async def _go():
loader.start()
with pytest.raises(RuntimeError, match="load failed"):
await loader.await_ready()
await asyncio.sleep(0)
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:
def test_returns_cached_agent_without_reawaiting(self):
async def test_returns_cached_agent_without_reawaiting(self):
captured: dict = {}
loader = BackgroundAgentLoader(_make_loader_fn("A", capture=captured))
async def _go():
loader.start()
assert await loader.await_ready() == "A"
assert await loader.await_ready() == "A"
loader.start()
assert await loader.await_ready() == "A"
assert await loader.await_ready() == "A"
_run(_go())
assert len(captured["kwargs"]) == 1
def test_raises_if_started_not_called(self):
async def test_raises_if_started_not_called(self):
loader = BackgroundAgentLoader(_make_loader_fn())
async def _go():
with pytest.raises(RuntimeError, match="before start"):
await loader.await_ready()
with pytest.raises(RuntimeError, match="before start"):
await loader.await_ready()
_run(_go())
def test_reraises_real_error_on_subsequent_awaits(self):
async 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."""
@@ -265,16 +262,13 @@ class TestBackgroundAgentLoaderAwaitReady:
loader = BackgroundAgentLoader(_fail)
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()
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()
_run(_go())
def test_needs_restart_flags_failed_load_for_retry(self):
async def test_needs_restart_flags_failed_load_for_retry(self):
calls = {"n": 0}
def flaky(*, on_mcp_progress=None):
@@ -285,17 +279,14 @@ class TestBackgroundAgentLoaderAwaitReady:
loader = BackgroundAgentLoader(flaky)
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())
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
class TestBackgroundAgentLoaderAdopt:
@@ -305,26 +296,19 @@ class TestBackgroundAgentLoaderAdopt:
assert loader.agent == "EXTERNAL"
assert not loader.is_pending
def test_adopt_supersedes_in_flight_load(self):
async def test_adopt_supersedes_in_flight_load(self):
"""A late background completion must not overwrite an adopted agent."""
import time
slow_loader = _GatedThreadLoader("FROM_BACKGROUND")
def _slow(*, on_mcp_progress=None):
time.sleep(0.08)
return "FROM_BACKGROUND"
loader = BackgroundAgentLoader(slow_loader)
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())
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"
class TestBackgroundAgentLoaderIsPending:
@@ -332,29 +316,22 @@ class TestBackgroundAgentLoaderIsPending:
loader = BackgroundAgentLoader(_make_loader_fn())
assert not loader.is_pending
def test_false_after_completion(self):
async def test_false_after_completion(self):
loader = BackgroundAgentLoader(_make_loader_fn())
async def _go():
loader.start()
await loader.await_ready()
loader.start()
await loader.await_ready()
_run(_go())
assert not loader.is_pending
def test_true_between_start_and_completion(self):
import time
async def test_true_between_start_and_completion(self):
wait_loader = _GatedThreadLoader("ok")
def _wait_loader(*, on_mcp_progress=None):
time.sleep(0.05)
return "ok"
loader = BackgroundAgentLoader(wait_loader)
loader = BackgroundAgentLoader(_wait_loader)
async def _go():
loader.start()
assert loader.is_pending
await loader.await_ready()
assert not loader.is_pending
_run(_go())
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
+129 -201
View File
@@ -5,6 +5,8 @@ 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,
@@ -28,12 +30,6 @@ 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)
@@ -51,7 +47,7 @@ def _drain_queue(q):
return items
def test_read_async_tasks_from_gateway_reads_state_values(run_async):
async def test_read_async_tasks_from_gateway_reads_state_values():
gateway = FakeGraphGateway(
state_values={
"async_tasks": {
@@ -60,18 +56,16 @@ def test_read_async_tasks_from_gateway_reads_state_values(run_async):
}
)
tasks = run_async(
async_notifier.read_async_tasks_from_gateway(
gateway,
GraphTarget(local_graph=MagicMock()),
"tid",
)
tasks = await async_notifier.read_async_tasks_from_gateway(
gateway,
GraphTarget(local_graph=MagicMock()),
"tid",
)
assert tasks == {"task-1": {"status": "success"}}
def test_watcher_pushes_notification_on_stream_end(run_async):
async def test_watcher_pushes_notification_on_stream_end():
# Stream yields one "values" chunk with the final state, then closes
final_state = {
"messages": [{"type": "ai", "content": "Quantum superposition is..."}]
@@ -87,10 +81,7 @@ def test_watcher_pushes_notification_on_stream_end(run_async):
# runs.get is used to fetch terminal status when stream ends
client.runs.get = AsyncMock(return_value={"status": "success"})
_drain_all(async_notifier)
run_async(
async_notifier.watch_run_and_notify(client, "thr-1", "run-1", "writing-agent")
)
await async_notifier.watch_run_and_notify(client, "thr-1", "run-1", "writing-agent")
notifs = _drain_queue(async_notifier._notification_queue)
assert len(notifs) == 1
@@ -99,7 +90,7 @@ def test_watcher_pushes_notification_on_stream_end(run_async):
assert notifs[0].status == "success"
def test_watcher_pushes_error_status_on_stream_exception(run_async):
async def test_watcher_pushes_error_status_on_stream_exception():
async def fake_stream(*a, **kw):
raise RuntimeError("network broken")
yield # unreachable; makes this an async generator
@@ -111,14 +102,13 @@ def test_watcher_pushes_error_status_on_stream_exception(run_async):
return_value={"status": "error", "error": "network broken"}
)
_drain_all(async_notifier)
run_async(async_notifier.watch_run_and_notify(client, "thr-4", "run-4", "agentZ"))
await async_notifier.watch_run_and_notify(client, "thr-4", "run-4", "agentZ")
notif = async_notifier._notification_queue.get_nowait()
assert notif.status == "error"
def test_spawn_watcher_replaces_existing_for_same_thread(run_async):
async def test_spawn_watcher_replaces_existing_for_same_thread():
"""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."""
@@ -138,43 +128,34 @@ def test_spawn_watcher_replaces_existing_for_same_thread(run_async):
client.runs.join_stream = fake_stream_long
client.runs.get = AsyncMock(return_value={"status": "success"})
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)
# 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
# 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
# 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
# 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
# Old watcher should be cancelled
await asyncio.sleep(0.02)
assert t1.cancelled() or t1.done()
# Old watcher should be cancelled
await asyncio.sleep(0.02)
assert t1.cancelled() or t1.done()
# Cleanup the new task too
t2.cancel()
try:
await t2
except asyncio.CancelledError:
pass
# 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())
# 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) == []
# ============================================================================
@@ -324,13 +305,6 @@ 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(
@@ -463,17 +437,12 @@ def test_dedup_preserves_order():
# ============================================================================
def test_consume_notifications_calls_runner_with_batched_message(run_async):
async def test_consume_notifications_calls_runner_with_batched_message():
"""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", "", ""))
@@ -486,21 +455,15 @@ def test_consume_notifications_calls_runner_with_batched_message(run_async):
async def fake_state_reader() -> dict:
return {} # no dedup info
run_async(an.consume_notifications(fake_runner, fake_state_reader))
await an.consume_notifications(fake_runner, fake_state_reader)
assert "wA" in captured["text"]
assert "wB" in captured["text"]
assert len(captured["notifs"]) == 2
def test_consume_notifications_no_op_when_queue_empty(run_async):
async def test_consume_notifications_no_op_when_queue_empty():
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):
@@ -510,7 +473,7 @@ def test_consume_notifications_no_op_when_queue_empty(run_async):
async def fake_state_reader():
return {}
run_async(an.consume_notifications(fake_runner, fake_state_reader))
await an.consume_notifications(fake_runner, fake_state_reader)
assert called is False
@@ -521,7 +484,7 @@ def test_consume_notifications_no_op_when_queue_empty(run_async):
# ============================================================================
def test_notification_consuming_flag_prevents_reentry(run_async):
async def test_notification_consuming_flag_prevents_reentry():
"""The _notification_consuming guard prevents two overlapping consumers.
Verifies the flag contract used by _consume_notifications_tui:
@@ -536,13 +499,6 @@ def test_notification_consuming_flag_prevents_reentry(run_async):
"""
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:
@@ -565,45 +521,42 @@ def test_notification_consuming_flag_prevents_reentry(run_async):
n1 = an.AsyncTaskNotification("g1", "writing-agent", "success", "", "")
n2 = an.AsyncTaskNotification("g2", "data-agent", "success", "", "")
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
# 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
run_async(scenario())
await guarded_consume_raising(n2)
assert state["consuming"] is False # cleared despite exception
# ============================================================================
@@ -613,33 +566,42 @@ def test_notification_consuming_flag_prevents_reentry(run_async):
def _drain_all(an_mod):
"""Drain every queue (per-thread + unrouted) so tests start clean."""
if hasattr(an_mod, "_notification_queue"):
while True:
try:
an_mod._notification_queue.get_nowait()
except queue.Empty:
break
for q in list(an_mod._notifications_by_thread.values()):
while True:
try:
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()
q.get_nowait()
except queue.Empty:
break
while True:
try:
an_mod._unrouted_queue.get_nowait()
except queue.Empty:
break
def test_consume_only_drains_matching_thread(run_async):
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():
"""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"
)
@@ -657,21 +619,17 @@ def test_consume_only_drains_matching_thread(run_async):
async def state_reader() -> dict:
return {}
run_async(
an.consume_notifications(runner, state_reader, current_thread_id="threadA")
)
await 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)
def test_unrouted_notifications_drain_on_any_thread(run_async):
async def test_unrouted_notifications_drain_on_any_thread():
"""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", "", "")
)
@@ -684,19 +642,15 @@ def test_unrouted_notifications_drain_on_any_thread(run_async):
async def state_reader() -> dict:
return {}
run_async(
an.consume_notifications(runner, state_reader, current_thread_id="anything")
)
await an.consume_notifications(runner, state_reader, current_thread_id="anything")
assert [n.task_id for n in captured["notifs"]] == ["tU"]
_drain_all(an)
def test_thread_switch_drains_pending(run_async):
async def test_thread_switch_drains_pending():
"""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"
@@ -712,25 +666,19 @@ def test_thread_switch_drains_pending(run_async):
return {}
# First consume in thread A → no drain, B's notif still queued
run_async(
an.consume_notifications(runner, state_reader, current_thread_id="threadA")
)
await 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
run_async(
an.consume_notifications(runner, state_reader, current_thread_id="threadB")
)
await 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", "", "")
@@ -748,7 +696,6 @@ 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)
# ============================================================================
@@ -762,7 +709,7 @@ def test_has_pending_notifications_respects_routing():
# ============================================================================
def test_watcher_reports_error_on_in_band_error_event(run_async):
async def test_watcher_reports_error_on_in_band_error_event():
"""SSE error event in the stream → notification.status == 'error'."""
async def fake_stream(*a, **kw):
@@ -777,8 +724,7 @@ def test_watcher_reports_error_on_in_band_error_event(run_async):
return_value={"status": "success"}
) # would mislead — should NOT be consulted
_drain_all(async_notifier)
run_async(async_notifier.watch_run_and_notify(client, "thrE", "rE", "agentE"))
await async_notifier.watch_run_and_notify(client, "thrE", "rE", "agentE")
notif = async_notifier._notification_queue.get_nowait()
assert notif.status == "error"
@@ -786,7 +732,7 @@ def test_watcher_reports_error_on_in_band_error_event(run_async):
client.runs.get.assert_not_awaited()
def test_watcher_clean_exit_with_runs_get_success_is_success(run_async):
async def test_watcher_clean_exit_with_runs_get_success_is_success():
"""Clean stream exit + runs.get reports success → status=success."""
async def fake_stream(*a, **kw):
@@ -798,15 +744,14 @@ def test_watcher_clean_exit_with_runs_get_success_is_success(run_async):
client.runs.join_stream = fake_stream
client.runs.get = AsyncMock(return_value={"status": "success"})
_drain_all(async_notifier)
run_async(async_notifier.watch_run_and_notify(client, "thrS", "rS", "agentS"))
await 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()
def test_watcher_clean_exit_with_runs_get_error_is_race_safe(run_async):
async def test_watcher_clean_exit_with_runs_get_error_is_race_safe():
"""Clean stream exit + no in-band error event + runs.get returns 'error'
→ status=success (race-safe).
@@ -827,14 +772,13 @@ def test_watcher_clean_exit_with_runs_get_error_is_race_safe(run_async):
client.runs.join_stream = fake_stream
client.runs.get = AsyncMock(return_value={"status": "error"})
_drain_all(async_notifier)
run_async(async_notifier.watch_run_and_notify(client, "thrS", "rS", "agentS"))
await async_notifier.watch_run_and_notify(client, "thrS", "rS", "agentS")
notif = async_notifier._notification_queue.get_nowait()
assert notif.status == "success"
def test_watcher_clean_exit_with_runs_get_running_drops_notification(run_async):
async def test_watcher_clean_exit_with_runs_get_running_drops_notification():
"""Reproduces the production bug: clean SSE close while run is still
actually running (HTTP keep-alive timeout under concurrency).
@@ -856,11 +800,8 @@ def test_watcher_clean_exit_with_runs_get_running_drops_notification(run_async):
client.runs.join_stream = fake_stream
client.runs.get = AsyncMock(return_value={"status": "running"})
_drain_all(async_notifier)
run_async(
async_notifier.watch_run_and_notify(
client, "thr-bug", "rB", "data-analysis-agent"
)
await async_notifier.watch_run_and_notify(
client, "thr-bug", "rB", "data-analysis-agent"
)
# No notification should have been enqueued anywhere.
@@ -872,7 +813,7 @@ def test_watcher_clean_exit_with_runs_get_running_drops_notification(run_async):
assert client.runs.get.await_count >= 1
def test_watcher_unknown_status_treated_as_non_terminal(run_async):
async def test_watcher_unknown_status_treated_as_non_terminal():
"""Future / unrecognized status values should trigger a re-join, not a
false-positive notification.
@@ -893,8 +834,7 @@ def test_watcher_unknown_status_treated_as_non_terminal(run_async):
side_effect=[{"status": "queued"}, {"status": "success"}]
)
_drain_all(async_notifier)
run_async(async_notifier.watch_run_and_notify(client, "thrU", "rU", "agentU"))
await async_notifier.watch_run_and_notify(client, "thrU", "rU", "agentU")
notif = async_notifier._notification_queue.get_nowait()
assert notif.status == "success"
@@ -902,7 +842,7 @@ def test_watcher_unknown_status_treated_as_non_terminal(run_async):
assert client.runs.get.await_count == 2
def test_watcher_runs_get_persistent_failure_drops_notification(run_async, monkeypatch):
async def test_watcher_runs_get_persistent_failure_drops_notification(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
@@ -921,22 +861,20 @@ def test_watcher_runs_get_persistent_failure_drops_notification(run_async, monke
monkeypatch.setattr(async_notifier.asyncio, "sleep", _no_sleep)
_drain_all(async_notifier)
run_async(async_notifier.watch_run_and_notify(client, "thrG", "rG", "agentG"))
await 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) == []
if hasattr(async_notifier, "_notifications_by_thread"):
for q in async_notifier._notifications_by_thread.values():
assert _drain_one_queue_helper(q) == []
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
def test_watcher_runs_get_transient_failure_recovers(run_async, monkeypatch):
async def test_watcher_runs_get_transient_failure_recovers(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
@@ -957,15 +895,14 @@ def test_watcher_runs_get_transient_failure_recovers(run_async, monkeypatch):
monkeypatch.setattr(async_notifier.asyncio, "sleep", _no_sleep)
_drain_all(async_notifier)
run_async(async_notifier.watch_run_and_notify(client, "thrT", "rT", "agentT"))
await 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
def test_watcher_re_joins_stream_until_terminal_status(run_async):
async def test_watcher_re_joins_stream_until_terminal_status():
"""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."""
@@ -980,8 +917,7 @@ def test_watcher_re_joins_stream_until_terminal_status(run_async):
side_effect=[{"status": "running"}, {"status": "success"}]
)
_drain_all(async_notifier)
run_async(async_notifier.watch_run_and_notify(client, "thrR", "rR", "agentR"))
await async_notifier.watch_run_and_notify(client, "thrR", "rR", "agentR")
notif = async_notifier._notification_queue.get_nowait()
assert notif.status == "success"
@@ -995,15 +931,12 @@ def test_watcher_re_joins_stream_until_terminal_status(run_async):
# ============================================================================
def test_consume_notifications_propagates_inject_exception(run_async):
async def test_consume_notifications_propagates_inject_exception():
"""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", "", "")
)
@@ -1015,11 +948,10 @@ def test_consume_notifications_propagates_inject_exception(run_async):
return {}
with pytest.raises(RuntimeError, match="kaboom"):
run_async(an.consume_notifications(boom_runner, state_reader))
_drain_all(an)
await an.consume_notifications(boom_runner, state_reader)
def test_watcher_skips_notification_on_stream_fail_with_nonterminal_status(run_async):
async def test_watcher_skips_notification_on_stream_fail_with_nonterminal_status():
"""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
@@ -1035,15 +967,13 @@ def test_watcher_skips_notification_on_stream_fail_with_nonterminal_status(run_a
client.runs.join_stream = fake_stream
client.runs.get = AsyncMock(return_value={"status": "pending"})
_drain_all(async_notifier)
run_async(async_notifier.watch_run_and_notify(client, "thrP", "rP", "agentP"))
await 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) == []
if hasattr(async_notifier, "_notifications_by_thread"):
for q in async_notifier._notifications_by_thread.values():
assert _drain_one_queue_helper(q) == []
for q in async_notifier._notifications_by_thread.values():
assert _drain_one_queue_helper(q) == []
def _drain_one_queue_helper(q):
@@ -1060,8 +990,6 @@ 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()
+22 -21
View File
@@ -7,7 +7,6 @@ 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
@@ -105,7 +104,7 @@ def _make_middleware():
return mw, fake_client
def test_middleware_spawns_watcher_on_start_async_task():
async 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
@@ -142,7 +141,7 @@ def test_middleware_spawns_watcher_on_start_async_task():
)
with patch.object(async_notifier, "spawn_watcher", side_effect=fake_spawn):
result = asyncio.run(mw.awrap_tool_call(request, fake_handler))
result = await mw.awrap_tool_call(request, fake_handler)
assert isinstance(result, Command)
assert spawn_calls == [
@@ -150,7 +149,7 @@ def test_middleware_spawns_watcher_on_start_async_task():
]
def test_middleware_spawns_watcher_on_update_async_task():
async 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
@@ -183,7 +182,7 @@ def test_middleware_spawns_watcher_on_update_async_task():
)
with patch.object(async_notifier, "spawn_watcher", side_effect=fake_spawn):
asyncio.run(mw.awrap_tool_call(request, fake_handler))
await mw.awrap_tool_call(request, fake_handler)
assert len(spawn_calls) == 1
args, kwargs = spawn_calls[0]
@@ -195,7 +194,7 @@ def test_middleware_spawns_watcher_on_update_async_task():
assert kwargs["origin_cli_thread_id"] == "cli-thread-A"
def test_middleware_pre_cancels_old_watcher_on_update():
async 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
@@ -221,14 +220,14 @@ def test_middleware_pre_cancels_old_watcher_on_update():
try:
with patch.object(async_notifier, "spawn_watcher"):
asyncio.run(mw.awrap_tool_call(request, fake_handler))
await 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
def test_middleware_passes_through_unrelated_tools():
async 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()
@@ -240,13 +239,13 @@ 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 = asyncio.run(mw.awrap_tool_call(request, fake_handler))
result = await mw.awrap_tool_call(request, fake_handler)
assert result is sentinel
assert mock_spawn.call_count == 0
def test_middleware_handles_non_command_results_gracefully():
async def test_middleware_handles_non_command_results_gracefully():
"""If the launch tool returns a string (validation error), no watcher is spawned."""
mw, _ = _make_middleware()
@@ -260,13 +259,13 @@ def test_middleware_handles_non_command_results_gracefully():
)
with patch.object(async_notifier, "spawn_watcher") as mock_spawn:
result = asyncio.run(mw.awrap_tool_call(request, fake_handler))
result = await mw.awrap_tool_call(request, fake_handler)
assert result == "Unknown async subagent type `bogus`"
assert mock_spawn.call_count == 0
def test_middleware_origin_thread_id_is_none_when_runtime_config_missing():
async 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
@@ -299,12 +298,12 @@ def test_middleware_origin_thread_id_is_none_when_runtime_config_missing():
)
with patch.object(async_notifier, "spawn_watcher", side_effect=fake_spawn):
asyncio.run(mw.awrap_tool_call(request, fake_handler))
await mw.awrap_tool_call(request, fake_handler)
assert captured.get("origin_cli_thread_id") is None
def test_middleware_swallows_spawn_exceptions():
async def test_middleware_swallows_spawn_exceptions():
"""spawn_watcher errors must not propagate up — middleware logs and continues."""
from langgraph.types import Command
@@ -336,7 +335,7 @@ def test_middleware_swallows_spawn_exceptions():
with patch.object(async_notifier, "spawn_watcher", side_effect=boom):
# Should not raise.
result = asyncio.run(mw.awrap_tool_call(request, fake_handler))
result = await mw.awrap_tool_call(request, fake_handler)
assert isinstance(result, Command)
@@ -356,7 +355,9 @@ def test_middleware_swallows_spawn_exceptions():
),
],
)
def test_middleware_picks_correct_prompt_field_per_tool(tool_name, args, prompt_field):
async 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
@@ -385,12 +386,12 @@ def test_middleware_picks_correct_prompt_field_per_tool(tool_name, args, prompt_
request = _build_request(tool_name, args, thread_id="t")
with patch.object(async_notifier, "spawn_watcher", side_effect=fake_spawn):
asyncio.run(mw.awrap_tool_call(request, fake_handler))
await mw.awrap_tool_call(request, fake_handler)
assert captured_prompt["value"] == prompt_field
def test_middleware_prompt_field_is_tool_name_gated_not_fallback_chained():
async 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')`
@@ -432,12 +433,12 @@ def test_middleware_prompt_field_is_tool_name_gated_not_fallback_chained():
)
with patch.object(async_notifier, "spawn_watcher", side_effect=fake_spawn):
asyncio.run(mw.awrap_tool_call(request, fake_handler))
await mw.awrap_tool_call(request, fake_handler)
assert captured_prompt["value"] == "use this"
def test_middleware_pre_cancel_swallows_unexpected_errors():
async 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
@@ -460,7 +461,7 @@ def test_middleware_pre_cancel_swallows_unexpected_errors():
try:
with patch.object(async_notifier, "spawn_watcher"):
# Should not raise.
asyncio.run(mw.awrap_tool_call(request, fake_handler))
await mw.awrap_tool_call(request, fake_handler)
finally:
async_notifier._watcher_by_thread.pop("t1", None)
+6 -7
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
import asyncio
import json
from types import SimpleNamespace
@@ -917,16 +916,16 @@ class _AsyncFakeCrons:
return [{"cron_id": "cron-async"}]
def test_alist_autoskill_schedules_uses_async_client_and_explicit_limit(monkeypatch):
async 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 = asyncio.run(
alist_autoskill_schedules(
EvoScientistConfig(),
limit=3,
)
rows = await 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):
def test_exited_elapsed_is_frozen(tmp_path, monkeypatch):
"""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):
proc = bg._PROCESSES[pid]
assert proc.finished_ts is not None
first = bg._elapsed(proc)
time.sleep(1.1) # intentional: prove elapsed stays frozen, not ticking up
monkeypatch.setattr(bg.time, "time", lambda: proc.finished_ts + 100.0)
assert bg._elapsed(proc) == first
+369 -403
View File
@@ -12,7 +12,6 @@ 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
@@ -58,7 +57,7 @@ def clean_channel_state():
class TestBusInboundConsumer:
"""Test the _bus_inbound_consumer queue bridge."""
def test_processes_inbound_and_publishes_outbound(self):
async def test_processes_inbound_and_publishes_outbound(self):
"""InboundMessage -> queue -> response -> OutboundMessage flow."""
from EvoScientist.cli.channel import (
_bus_inbound_consumer,
@@ -68,54 +67,51 @@ class TestBusInboundConsumer:
_drain_queue(_message_queue)
async def _test():
bus = MessageBus()
manager = ChannelManager(bus)
ch = FakeChannel()
manager.register(ch)
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
_run(_test())
def test_no_response_fallback(self):
async def test_no_response_fallback(self):
"""Empty response is replaced with 'No response' fallback."""
from EvoScientist.cli.channel import (
_bus_inbound_consumer,
@@ -125,47 +121,44 @@ class TestBusInboundConsumer:
_drain_queue(_message_queue)
async def _test():
bus = MessageBus()
manager = ChannelManager(bus)
ch = FakeChannel()
manager.register(ch)
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
_run(_test())
def test_late_response_after_timeout_still_publishes(self, monkeypatch):
async 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 (
@@ -179,56 +172,53 @@ class TestBusInboundConsumer:
_drain_queue(_message_queue)
async def _test():
bus = MessageBus()
manager = ChannelManager(bus)
ch = FakeChannel()
manager.register(ch)
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
_run(_test())
def test_late_timeout_keeps_active_request_cancellable(self, monkeypatch):
async 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 (
@@ -243,191 +233,179 @@ class TestBusInboundConsumer:
monkeypatch.setattr(channel_mod, "_RESPONSE_TIMEOUT", 0.05)
monkeypatch.setattr(channel_mod, "_LATE_RESPONSE_TIMEOUT", 0.05)
async def _test():
bus = MessageBus()
manager = ChannelManager(bus)
ch = FakeChannel()
manager.register(ch)
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="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(
task = asyncio.create_task(
_handle_bus_message(
bus,
manager,
InboundMessage(
channel="fake",
sender_id="user1",
chat_id="chat1",
content="/stop",
message_id="msg-stop-active",
content="still running",
message_id="msg-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)
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)
_run(_test())
assert queued is not None
assert _claim_channel_request(queued) is True
def test_cancelled_wait_cleans_pending_response(self):
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):
"""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
async def _test():
bus = MessageBus()
manager = ChannelManager(bus)
ch = FakeChannel()
manager.register(ch)
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
_run(_test())
def test_consumer_shutdown_cleans_pending_response(self):
async 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
async def _test():
bus = MessageBus()
manager = ChannelManager(bus)
ch = FakeChannel()
manager.register(ch)
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
_run(_test())
def test_stop_during_hitl_wait_releases_wait_and_acks(self):
async 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
async def _test():
bus = MessageBus()
manager = ChannelManager(bus)
ch = FakeChannel()
manager.register(ch)
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
_run(_test())
def test_stop_cancels_queued_request_before_main_thread_processes_it(self):
async 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 (
@@ -436,73 +414,70 @@ class TestBusInboundConsumer:
_message_queue,
)
async def _test():
bus = MessageBus()
manager = ChannelManager(bus)
ch = FakeChannel()
manager.register(ch)
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="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(
task = asyncio.create_task(
_handle_bus_message(
bus,
manager,
InboundMessage(
channel="fake",
sender_id="user1",
chat_id="chat1",
content="/stop",
message_id="m-stop-2",
content="please work",
message_id="m-work-1",
),
)
)
with pytest.raises(asyncio.CancelledError):
await task
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)
skipped = _message_queue.get_nowait()
assert skipped.msg_id == queued.msg_id
assert _claim_or_complete_channel_request(skipped) is False
assert queued is not None
with channel_mod._response_lock:
assert queued.msg_id in channel_mod._pending_responses
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
await _handle_bus_message(
bus,
manager,
InboundMessage(
channel="fake",
sender_id="user1",
chat_id="chat1",
content="/stop",
message_id="m-stop-2",
),
)
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)
with pytest.raises(asyncio.CancelledError):
await task
_run(_test())
skipped = _message_queue.get_nowait()
assert skipped.msg_id == queued.msg_id
assert _claim_or_complete_channel_request(skipped) is False
def test_stop_leaves_resolved_response_available_for_delivery(self):
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):
"""`/stop` must not steal a response whose waiter already resolved."""
from EvoScientist.cli import channel as channel_mod
from EvoScientist.cli.channel import (
@@ -515,42 +490,39 @@ class TestBusInboundConsumer:
_set_channel_response,
)
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",
)
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)
_run(_test())
def test_message_counting(self):
async def test_message_counting(self):
"""Messages are counted via record_message."""
from EvoScientist.cli.channel import (
_bus_inbound_consumer,
@@ -560,45 +532,42 @@ class TestBusInboundConsumer:
_drain_queue(_message_queue)
async def _test():
bus = MessageBus()
manager = ChannelManager(bus)
ch = FakeChannel()
manager.register(ch)
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
_run(_test())
def test_channel_message_carries_metadata(self):
async def test_channel_message_carries_metadata(self):
"""ChannelMessage carries metadata, chat_id, and message_id."""
from EvoScientist.cli.channel import (
_bus_inbound_consumer,
@@ -608,49 +577,46 @@ class TestBusInboundConsumer:
_drain_queue(_message_queue)
async def _test():
bus = MessageBus()
manager = ChannelManager(bus)
ch = FakeChannel()
manager.register(ch)
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
_run(_test())
consumer.cancel()
try:
await consumer
except asyncio.CancelledError:
pass
+10 -12
View File
@@ -3,8 +3,6 @@
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
@@ -55,7 +53,7 @@ class TestNeedsAgent:
class TestStartPath:
"""Start flow must propagate agent/thread_id globals."""
def test_start_binds_channel_runtime(self):
async def test_start_binds_channel_runtime(self):
from EvoScientist.commands.implementation.channel import ChannelCommand
ctx, _ui = _ctx()
@@ -77,11 +75,11 @@ class TestStartPath:
return_value=config,
),
):
_run(ChannelCommand().execute(ctx, ["telegram"]))
await ChannelCommand().execute(ctx, ["telegram"])
assert ctx.channel_runtime.agent is ctx.agent
assert ctx.channel_runtime.thread_id == "tid-42"
def test_start_propagates_send_thinking(self):
async def test_start_propagates_send_thinking(self):
"""send_thinking flag must reach _start_channels_bus_mode."""
from EvoScientist.commands.implementation.channel import ChannelCommand
@@ -111,14 +109,14 @@ class TestStartPath:
return_value=config,
),
):
_run(ChannelCommand().execute(ctx, ["telegram"]))
await ChannelCommand().execute(ctx, ["telegram"])
assert captured["agent"] is ctx.agent
assert captured["thread_id"] == "tid-42"
assert captured["send_thinking"] is False
class TestAddToRunningPath:
def test_add_to_running_binds_channel_runtime(self):
async def test_add_to_running_binds_channel_runtime(self):
from EvoScientist.commands.implementation.channel import ChannelCommand
ctx, _ui = _ctx()
@@ -140,11 +138,11 @@ class TestAddToRunningPath:
return_value=config,
),
):
_run(ChannelCommand().execute(ctx, ["discord"]))
await ChannelCommand().execute(ctx, ["discord"])
assert ctx.channel_runtime.agent is ctx.agent
assert ctx.channel_runtime.thread_id == "tid-42"
def test_add_to_running_propagates_send_thinking(self):
async 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
@@ -173,13 +171,13 @@ class TestAddToRunningPath:
return_value=config,
),
):
_run(ChannelCommand().execute(ctx, ["discord"]))
await ChannelCommand().execute(ctx, ["discord"])
assert captured["channel_type"] == "discord"
assert captured["send_thinking"] is True
class TestStatusPath:
def test_status_without_running_channels(self):
async def test_status_without_running_channels(self):
from EvoScientist.commands.implementation.channel import ChannelCommand
ctx, ui = _ctx()
@@ -198,6 +196,6 @@ class TestStatusPath:
return_value=config,
),
):
_run(ChannelCommand().execute(ctx, ["status"]))
await 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)
+8 -9
View File
@@ -8,7 +8,6 @@ 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
@@ -57,7 +56,7 @@ def _sent_text(bus_ref) -> str:
)
def test_handle_session_resume_sends_history_back_to_channel_without_local_duplicate():
async def test_handle_session_resume_sends_history_back_to_channel_without_local_duplicate():
callback = AsyncMock()
bus_ref = SimpleNamespace(publish_outbound=AsyncMock())
@@ -72,7 +71,7 @@ def test_handle_session_resume_sends_history_back_to_channel_without_local_dupli
thread_store=thread_store,
)
_run(_run_resume(ui, "thread-42", "/workspace"))
await _run_resume(ui, "thread-42", "/workspace")
callback.assert_awaited_once_with("thread-42", "/workspace")
assert thread_store.calls == [("get_thread_messages", "thread-42")]
@@ -84,7 +83,7 @@ def test_handle_session_resume_sends_history_back_to_channel_without_local_dupli
assert "EvoScientist: Here is the saved answer." in text
def test_handle_session_resume_propagates_callback_abort_without_history():
async 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()
@@ -95,7 +94,7 @@ def test_handle_session_resume_propagates_callback_abort_without_history():
)
with pytest.raises(RuntimeError, match="workspace conflict"):
_run(_run_resume(ui, "thread-42", "/workspace"))
await _run_resume(ui, "thread-42", "/workspace")
callback.assert_awaited_once_with("thread-42", "/workspace")
assert thread_store.calls == []
@@ -103,7 +102,7 @@ def test_handle_session_resume_propagates_callback_abort_without_history():
assert captured == []
def test_handle_session_resume_reports_history_load_error():
async def test_handle_session_resume_reports_history_load_error():
callback = AsyncMock()
bus_ref = SimpleNamespace(publish_outbound=AsyncMock())
ui, captured = _make_ui(
@@ -114,7 +113,7 @@ def test_handle_session_resume_reports_history_load_error():
),
)
_run(_run_resume(ui, "thread-42", "/workspace"))
await _run_resume(ui, "thread-42", "/workspace")
callback.assert_awaited_once_with("thread-42", "/workspace")
assert captured == []
@@ -123,7 +122,7 @@ def test_handle_session_resume_reports_history_load_error():
assert "history unavailable: db locked" in text
def test_handle_session_resume_distinguishes_non_displayable_messages():
async def test_handle_session_resume_distinguishes_non_displayable_messages():
bus_ref = SimpleNamespace(publish_outbound=AsyncMock())
ui, captured = _make_ui(
bus_ref=bus_ref,
@@ -132,7 +131,7 @@ def test_handle_session_resume_distinguishes_non_displayable_messages():
),
)
_run(_run_resume(ui, "thread-42", "/workspace"))
await _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
+27 -58
View File
@@ -11,8 +11,6 @@ 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
@@ -75,10 +73,10 @@ def _make_channel_context(*, debug_trace=True, name="test_channel"):
return {"channel": channel}
def test_middleware_dedup_emits_structured_event(caplog):
async def test_middleware_dedup_emits_structured_event(caplog):
from EvoScientist.channels.middleware import DedupMiddleware
async def _run():
with caplog.at_level(logging.DEBUG):
mw = DedupMiddleware()
ctx = _make_channel_context()
raw = _make_raw(message_id="dup1")
@@ -91,49 +89,40 @@ 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
def test_middleware_allowlist_emits_structured_event(caplog):
async def test_middleware_allowlist_emits_structured_event(caplog):
from EvoScientist.channels.middleware import AllowListMiddleware
async def _run():
with caplog.at_level(logging.DEBUG):
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
def test_middleware_mention_gating_emits_structured_event(caplog):
async def test_middleware_mention_gating_emits_structured_event(caplog):
from EvoScientist.channels.middleware import MentionGatingMiddleware
async def _run():
with caplog.at_level(logging.DEBUG):
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
def test_typing_manager_emits_trace_events(caplog):
async def test_typing_manager_emits_trace_events(caplog):
from EvoScientist.channels.middleware import TypingManager
async def _run():
with caplog.at_level(logging.DEBUG):
send_action = AsyncMock(side_effect=RuntimeError("typing api down"))
mgr = TypingManager(
send_action,
@@ -144,17 +133,14 @@ 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
def test_ack_reaction_emits_error_traces(caplog):
async def test_ack_reaction_emits_error_traces(caplog):
from EvoScientist.channels.middleware import AckReactionMiddleware
async def _run():
with caplog.at_level(logging.DEBUG):
send_fn = AsyncMock()
remove_fn = AsyncMock(side_effect=RuntimeError("remove failed"))
ack = AckReactionMiddleware(
@@ -178,16 +164,13 @@ 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
def test_inbound_raw_event_emitted(caplog):
async def test_inbound_raw_event_emitted(caplog):
"""Integration-style: _enqueue_raw emits inbound_raw at the top."""
from EvoScientist.channels.base import Channel, RawIncoming
@@ -218,19 +201,16 @@ def test_inbound_raw_event_emitted(caplog):
config.ack_scope = "off"
config.dedup_ttl = 3600
async def _run():
with caplog.at_level(logging.DEBUG):
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
def test_format_fallback_emits_event(caplog):
async def test_format_fallback_emits_event(caplog):
"""_send_with_format_fallback emits outbound_format_fallback on fallback."""
from EvoScientist.channels.base import Channel
@@ -268,13 +248,10 @@ def test_format_fallback_emits_event(caplog):
if call_count == 1:
raise ValueError("parse error in formatted text")
async def _run():
with caplog.at_level(logging.DEBUG):
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
@@ -304,7 +281,7 @@ def test_trace_mixin_trace_event(caplog):
assert "key=val" in caplog.text
def test_standalone_dispatcher_treats_false_send_as_error(caplog):
async 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
@@ -315,7 +292,7 @@ def test_standalone_dispatcher_treats_false_send_as_error(caplog):
channel.send = AsyncMock(return_value=False)
bus = MessageBus()
async def _run():
with caplog.at_level(logging.DEBUG):
task = asyncio.create_task(standalone_outbound_dispatcher(bus, channel))
await bus.publish_outbound(
OutboundMessage(channel="test", chat_id="c1", content="hi")
@@ -326,14 +303,11 @@ 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
def test_standalone_dispatcher_sends_media():
async 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
@@ -345,21 +319,16 @@ def test_standalone_dispatcher_sends_media():
channel.send_media = AsyncMock(return_value=True)
bus = MessageBus()
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())
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
channel.send_media.assert_awaited_once_with(
recipient="c1",
file_path="/tmp/a.png",
+145 -180
View File
@@ -14,7 +14,6 @@ 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
@@ -43,25 +42,23 @@ def _make_msg(
)
def test_non_slash_returns_false():
async def test_non_slash_returns_false():
"""Plain text messages must fall through to the agent."""
msg = _make_msg(content="hello agent")
append = MagicMock()
handled = _run(
dispatch_channel_slash_command(
msg,
agent=None,
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=append,
)
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()
def test_unresolved_slash_returns_false():
async def test_unresolved_slash_returns_false():
"""Unknown slash commands must fall through (matches TUI behavior)."""
msg = _make_msg(content="/unknown-cmd")
append = MagicMock()
@@ -69,20 +66,18 @@ def test_unresolved_slash_returns_false():
"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,
)
handled = await dispatch_channel_slash_command(
msg,
agent=None,
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=append,
)
assert handled is False
def test_successful_slash_execution_sets_response_and_breadcrumb():
async 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()
@@ -100,15 +95,13 @@ def test_successful_slash_execution_sets_response_and_breadcrumb():
) as mock_execute,
patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp,
):
handled = _run(
dispatch_channel_slash_command(
msg,
agent="fake-agent",
thread_id="t1",
workspace_dir="/tmp",
checkpointer=None,
append_system=append,
)
handled = await 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()
@@ -119,7 +112,7 @@ def test_successful_slash_execution_sets_response_and_breadcrumb():
assert any("Executed command from" in t for t in breadcrumbs)
def test_slash_dispatch_passes_graph_gateway_to_command_context():
async def test_slash_dispatch_passes_graph_gateway_to_command_context():
msg = _make_msg()
fake_cmd = MagicMock()
fake_cmd.needs_agent.return_value = False
@@ -142,23 +135,21 @@ def test_slash_dispatch_passes_graph_gateway_to_command_context():
),
patch("EvoScientist.cli.channel._set_channel_response"),
):
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,
)
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,
)
assert handled is True
assert captured["graph_gateway"] is graph_gateway
def test_needs_agent_awaits_loader_and_passes_result():
async 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()
@@ -182,16 +173,14 @@ def test_needs_agent_awaits_loader_and_passes_result():
) as mock_execute,
patch("EvoScientist.cli.channel._set_channel_response"),
):
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,
)
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,
)
assert handled is True
await_called.assert_called_once()
@@ -201,7 +190,7 @@ def test_needs_agent_awaits_loader_and_passes_result():
assert ctx_arg.agent == "ready-agent"
def test_await_agent_ready_failure_sets_error_response():
async def test_await_agent_ready_failure_sets_error_response():
msg = _make_msg()
fake_cmd = MagicMock()
fake_cmd.needs_agent.return_value = True
@@ -217,16 +206,14 @@ def test_await_agent_ready_failure_sets_error_response():
),
patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp,
):
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,
)
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,
)
assert handled is True
mock_set_resp.assert_called_once()
@@ -235,7 +222,7 @@ def test_await_agent_ready_failure_sets_error_response():
assert "agent blew up" in resp_text
def test_cmd_manager_raises_returns_true_with_error():
async 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()
@@ -253,15 +240,13 @@ def test_cmd_manager_raises_returns_true_with_error():
),
patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp,
):
handled = _run(
dispatch_channel_slash_command(
msg,
agent=None,
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=append,
)
handled = await 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()
@@ -270,7 +255,7 @@ def test_cmd_manager_raises_returns_true_with_error():
assert "boom" in resp_text
def test_on_cmd_completed_awaited_with_ctx_original_agent_and_cmd():
async 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."""
@@ -302,16 +287,14 @@ def test_on_cmd_completed_awaited_with_ctx_original_agent_and_cmd():
),
patch("EvoScientist.cli.channel._set_channel_response"),
):
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,
)
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,
)
assert handled is True
assert captured["ctx_agent"] == "swapped-agent"
@@ -319,7 +302,7 @@ def test_on_cmd_completed_awaited_with_ctx_original_agent_and_cmd():
assert captured["cmd_name"] == "/model"
def test_on_cmd_completed_receives_cmd_for_new_and_compact():
async 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``."""
@@ -343,21 +326,19 @@ def test_on_cmd_completed_receives_cmd_for_new_and_compact():
),
patch("EvoScientist.cli.channel._set_channel_response"),
):
_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,
)
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,
)
assert captured["cmd_name"] == cmd_name, cmd_name
def test_on_cmd_completed_skipped_on_fall_through_and_error():
async 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()
@@ -369,16 +350,14 @@ def test_on_cmd_completed_skipped_on_fall_through_and_error():
# Non-slash
with patch("EvoScientist.cli.channel._set_channel_response"):
_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,
)
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,
)
# Unresolved slash
with (
@@ -388,16 +367,14 @@ def test_on_cmd_completed_skipped_on_fall_through_and_error():
),
patch("EvoScientist.cli.channel._set_channel_response"),
):
_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,
)
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,
)
# Execute raises
with (
@@ -411,22 +388,20 @@ def test_on_cmd_completed_skipped_on_fall_through_and_error():
),
patch("EvoScientist.cli.channel._set_channel_response"),
):
_run(
dispatch_channel_slash_command(
_make_msg(),
agent=None,
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=MagicMock(),
on_cmd_completed=_noop,
)
await 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()
def test_command_error_skips_completion_hook_and_reports_error():
async 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()
@@ -450,16 +425,14 @@ def test_command_error_skips_completion_hook_and_reports_error():
),
patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp,
):
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,
)
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,
)
assert handled is True
@@ -467,7 +440,7 @@ def test_command_error_skips_completion_hook_and_reports_error():
mock_set_resp.assert_called_once_with("msg-1", "Command error: workspace conflict")
def test_empty_command_error_still_reports_error():
async 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()
@@ -489,16 +462,14 @@ def test_empty_command_error_still_reports_error():
),
patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp,
):
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,
)
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,
)
assert handled is True
@@ -506,7 +477,7 @@ def test_empty_command_error_still_reports_error():
mock_set_resp.assert_called_once_with("msg-1", "Command error: (no details)")
def test_on_cmd_completed_exception_is_absorbed():
async 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()
@@ -526,23 +497,21 @@ def test_on_cmd_completed_exception_is_absorbed():
),
patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp,
):
handled = _run(
dispatch_channel_slash_command(
msg,
agent="orig",
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=MagicMock(),
on_cmd_completed=_boom,
)
handled = await 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]
def test_top_level_exception_is_absorbed():
async 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
@@ -557,15 +526,13 @@ def test_top_level_exception_is_absorbed():
),
patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp,
):
handled = _run(
dispatch_channel_slash_command(
msg,
agent=None,
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=MagicMock(),
)
handled = await 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()
@@ -574,7 +541,7 @@ def test_top_level_exception_is_absorbed():
assert "exploded during resolve" in resp_text
def test_cmd_execute_returning_false_falls_through():
async 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="/")
@@ -592,15 +559,13 @@ def test_cmd_execute_returning_false_falls_through():
),
patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp,
):
handled = _run(
dispatch_channel_slash_command(
msg,
agent=None,
thread_id="t1",
workspace_dir=None,
checkpointer=None,
append_system=append,
)
handled = await 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()
+4 -7
View File
@@ -1,6 +1,5 @@
"""Tests for CLI interactive UI backend dispatch."""
import asyncio
from types import SimpleNamespace
import pytest
@@ -101,7 +100,7 @@ def test_background_agent_server_starts_even_when_async_subagents_disabled(
assert calls == [(config, "/tmp/workspace")]
def test_resume_workspace_sync_runs_even_when_async_subagents_disabled(
async def test_resume_workspace_sync_runs_even_when_async_subagents_disabled(
monkeypatch,
):
import EvoScientist.cli.commands as cmds
@@ -117,11 +116,9 @@ def test_resume_workspace_sync_runs_even_when_async_subagents_disabled(
)
config = SimpleNamespace(enable_async_subagents=False)
asyncio.run(
cmds._sync_background_agent_server_workspace(
config,
workspace_dir="/tmp/resumed-workspace",
)
await cmds._sync_background_agent_server_workspace(
config,
workspace_dir="/tmp/resumed-workspace",
)
assert calls == [(config, "/tmp/resumed-workspace")]
+24 -27
View File
@@ -4,13 +4,12 @@ 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()
def _compact(
async def _compact(
graph_gateway: FakeGraphGateway,
*,
thread_id: str = "tid-1",
@@ -18,30 +17,28 @@ def _compact(
):
from EvoScientist.cli.commands import compact_conversation
return _run(
compact_conversation(
graph_gateway=graph_gateway,
thread_id=thread_id,
target=_TARGET,
input_tokens_hint=input_tokens_hint,
)
return await 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."""
def test_empty_messages(self):
async def test_empty_messages(self):
graph_gateway = FakeGraphGateway(state_values={"messages": []})
result = _compact(graph_gateway)
result = await _compact(graph_gateway)
assert result.status == "noop"
assert "no messages" in result.message
def test_state_read_failure(self):
async def test_state_read_failure(self):
graph_gateway = FakeGraphGateway(state_error=RuntimeError("DB gone"))
result = _compact(graph_gateway)
result = await _compact(graph_gateway)
assert result.status == "error"
assert "Failed to read state" in result.message
@@ -49,7 +46,7 @@ class TestCompactGuards:
class TestCompactCutoffZero:
"""When cutoff == 0, conversation is within retention budget."""
def test_nothing_to_compact_short_conversation(self):
async def test_nothing_to_compact_short_conversation(self):
msgs = [MagicMock() for _ in range(3)]
graph_gateway = FakeGraphGateway(state_values={"messages": msgs})
@@ -79,7 +76,7 @@ class TestCompactCutoffZero:
return_value=500,
),
):
result = _compact(graph_gateway)
result = await _compact(graph_gateway)
assert result.status == "noop"
assert "within the retention budget" in result.message
@@ -89,7 +86,7 @@ class TestCompactCutoffZero:
class TestCompactNegligibleSavings:
"""When cutoff > 0 but savings are too small to be worth it."""
def test_skip_when_few_messages_and_low_tokens(self):
async 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}
@@ -126,14 +123,14 @@ class TestCompactNegligibleSavings:
side_effect=lambda x: next(token_values),
),
):
result = _compact(graph_gateway)
result = await _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()
def test_still_compacts_when_few_messages_but_high_tokens(self):
async 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
@@ -178,7 +175,7 @@ class TestCompactNegligibleSavings:
side_effect=lambda x: next(token_values),
),
):
result = _compact(graph_gateway)
result = await _compact(graph_gateway)
assert result.status == "ok"
assert len(graph_gateway.updated_states) == 1
@@ -187,7 +184,7 @@ class TestCompactNegligibleSavings:
class TestCompactSuccess:
"""Normal compaction flow."""
def test_manual_threshold_blocks_low_context_compaction(self):
async 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}
@@ -217,7 +214,7 @@ class TestCompactSuccess:
return_value=30_000,
),
):
result = _compact(graph_gateway)
result = await _compact(graph_gateway)
assert result.status == "noop"
assert "40%" in result.message
@@ -225,7 +222,7 @@ class TestCompactSuccess:
mock_middleware_inst._determine_cutoff_index.assert_not_called()
mock_middleware_inst._acreate_summary.assert_not_called()
def test_successful_compaction(self):
async def test_successful_compaction(self):
from langchain_core.messages import HumanMessage
msgs = [MagicMock() for _ in range(20)]
@@ -273,7 +270,7 @@ class TestCompactSuccess:
side_effect=lambda x: next(token_values),
),
):
result = _compact(graph_gateway)
result = await _compact(graph_gateway)
assert result.status == "ok"
assert result.messages_compacted == 15
@@ -291,7 +288,7 @@ class TestCompactSuccess:
assert "_summarization_event" in event_data
assert event_data["_summarization_event"]["cutoff_index"] == 15
def test_offload_failure_non_fatal(self):
async def test_offload_failure_non_fatal(self):
"""Offload failure should not prevent compaction."""
from langchain_core.messages import HumanMessage
@@ -335,7 +332,7 @@ class TestCompactSuccess:
return_value=1000,
),
):
result = _compact(graph_gateway)
result = await _compact(graph_gateway)
assert result.status == "ok"
assert len(graph_gateway.updated_states) == 1
@@ -377,7 +374,7 @@ class TestRenderCompactResult:
class TestCompactCommandUI:
"""TUI-specific compact progress indicator behavior."""
def test_command_uses_tui_indicator_when_available(self):
async 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
@@ -413,7 +410,7 @@ class TestCompactCommandUI:
return_value="summary-panel",
),
):
_run(CompactCommand().execute(ctx, []))
await CompactCommand().execute(ctx, [])
assert ui.started == 1
assert ui.stopped == 1
+17
View File
@@ -242,6 +242,23 @@ 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(
+6 -7
View File
@@ -15,7 +15,6 @@ from EvoScientist.middleware.configurable_model import (
ConfigurableModelMiddleware,
_read_model_override,
)
from tests.conftest import run_async as _run
@contextmanager
@@ -134,7 +133,7 @@ class TestPassThrough:
handler.assert_called_once_with(req)
req.override.assert_not_called()
def test_async_no_override_passes_request_unchanged(self):
async def test_async_no_override_passes_request_unchanged(self):
mw = ConfigurableModelMiddleware()
req = _make_request()
@@ -143,7 +142,7 @@ class TestPassThrough:
return "ok"
with _patched_config({}):
result = _run(mw.awrap_model_call(req, handler))
result = await mw.awrap_model_call(req, handler)
assert result == "ok"
req.override.assert_not_called()
@@ -185,7 +184,7 @@ class TestModelOverride:
assert called_with is not req
assert called_with.model is new_model
def test_async_override_path_parity(self):
async def test_async_override_path_parity(self):
mw = ConfigurableModelMiddleware()
req = _make_request()
new_model = MagicMock()
@@ -202,7 +201,7 @@ class TestModelOverride:
"EvoScientist.llm.get_chat_model", return_value=new_model
) as mock_get,
):
result = _run(mw.awrap_model_call(req, handler))
result = await mw.awrap_model_call(req, handler)
assert result == "ok"
mock_get.assert_called_once_with(model="claude-opus-4-8", provider="anthropic")
@@ -316,7 +315,7 @@ class TestResolveFailure:
handler.assert_called_once_with(req)
req.override.assert_not_called()
def test_async_falls_back_when_resolve_raises(self):
async def test_async_falls_back_when_resolve_raises(self):
mw = ConfigurableModelMiddleware()
req = _make_request()
@@ -333,7 +332,7 @@ class TestResolveFailure:
side_effect=ValueError("unknown model"),
),
):
result = _run(mw.awrap_model_call(req, handler))
result = await mw.awrap_model_call(req, handler)
assert result == "ok"
assert called == [req]
@@ -66,7 +66,6 @@ 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)]
@@ -91,7 +90,6 @@ 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=[],
+4 -6
View File
@@ -2,11 +2,9 @@
from unittest.mock import MagicMock
from tests.conftest import run_async as _run
class TestCurrentCommand:
def test_prints_thread_workspace_and_memory(self):
async def test_prints_thread_workspace_and_memory(self):
from EvoScientist.commands.base import CommandContext
from EvoScientist.commands.implementation.general import CurrentCommand
@@ -17,14 +15,14 @@ class TestCurrentCommand:
ui=ui,
workspace_dir="/tmp/ws",
)
_run(CurrentCommand().execute(ctx, []))
await 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)
def test_skips_workspace_when_none(self):
async def test_skips_workspace_when_none(self):
from EvoScientist.commands.base import CommandContext
from EvoScientist.commands.implementation.general import CurrentCommand
@@ -35,7 +33,7 @@ class TestCurrentCommand:
ui=ui,
workspace_dir=None,
)
_run(CurrentCommand().execute(ctx, []))
await 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)
+14 -15
View File
@@ -2,7 +2,6 @@
from unittest.mock import AsyncMock, MagicMock
from tests.conftest import run_async as _run
from tests.fakes import FakeGraphGateway, FakeThreadStore
@@ -21,17 +20,17 @@ def _ctx(thread_id="current", thread_store=None):
class TestDeleteCommand:
def test_refuses_to_delete_current(self):
async 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)
_run(DeleteCommand().execute(ctx, ["current"]))
await 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)
def test_happy_path_success(self):
async def test_happy_path_success(self):
from EvoScientist.commands.implementation.session import DeleteCommand
ctx, ui = _ctx(
@@ -41,45 +40,45 @@ class TestDeleteCommand:
deleted=True,
),
)
_run(DeleteCommand().execute(ctx, ["other-thread"]))
await 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)
def test_not_found(self):
async def test_not_found(self):
from EvoScientist.commands.implementation.session import DeleteCommand
ctx, ui = _ctx()
_run(DeleteCommand().execute(ctx, ["missing"]))
await 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)
def test_ambiguous_prefix(self):
async def test_ambiguous_prefix(self):
from EvoScientist.commands.implementation.session import DeleteCommand
ctx, ui = _ctx(thread_store=FakeThreadStore(matches=["abc-one", "abc-two"]))
_run(DeleteCommand().execute(ctx, ["abc"]))
await 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)
def test_prefix_resolves_to_unique_match(self):
async 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)
)
_run(DeleteCommand().execute(ctx, ["abc"]))
await 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)
def test_no_arg_empty_sessions_prints_notice(self):
async def test_no_arg_empty_sessions_prints_notice(self):
from EvoScientist.commands.implementation.session import DeleteCommand
ctx, ui = _ctx()
_run(DeleteCommand().execute(ctx, []))
await 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)
def test_no_arg_calls_picker_returns_none(self):
async 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
@@ -96,5 +95,5 @@ class TestDeleteCommand:
]
store = FakeThreadStore(threads=threads)
ctx.graph_gateway = FakeGraphGateway(thread_store=store)
_run(DeleteCommand().execute(ctx, []))
await DeleteCommand().execute(ctx, [])
ui.wait_for_thread_pick.assert_awaited_once()
+32 -33
View File
@@ -7,7 +7,6 @@ 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:
@@ -41,30 +40,30 @@ class TestDingTalkChannel:
assert channel._running is False
assert channel.name == "dingtalk"
def test_start_raises_without_credentials(self):
async 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"):
_run(channel.start())
await channel.start()
def test_start_raises_without_client_id(self):
async 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"):
_run(channel.start())
await channel.start()
def test_start_raises_without_client_secret(self):
async 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"):
_run(channel.start())
await channel.start()
def test_stop_when_not_running(self):
async def test_stop_when_not_running(self):
config = DingTalkConfig(client_id="test-id", client_secret="test-secret")
channel = DingTalkChannel(config)
_run(channel.stop())
await channel.stop()
def test_send_returns_false_without_client(self):
async def test_send_returns_false_without_client(self):
config = DingTalkConfig(client_id="test-id", client_secret="test-secret")
channel = DingTalkChannel(config)
msg = OutboundMessage(
@@ -73,7 +72,7 @@ class TestDingTalkChannel:
content="hello",
metadata={"chat_id": "user123"},
)
result = _run(channel.send(msg))
result = await channel.send(msg)
assert result is False
def test_capabilities(self):
@@ -130,20 +129,20 @@ class TestDingTalkWsMessageParsing:
channel._token_expires = 9999999999
return channel
def test_system_ping_ack(self):
async def test_system_ping_ack(self):
channel = self._make_channel()
data = {
"type": "SYSTEM",
"headers": {"topic": "ping", "messageId": "ping-1"},
"data": "pong-data",
}
_run(channel._on_ws_message(data))
await 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"
def test_callback_text_message(self):
async def test_callback_text_message(self):
channel = self._make_channel()
channel._enqueue_raw = AsyncMock()
@@ -158,14 +157,14 @@ class TestDingTalkWsMessageParsing:
"headers": {"messageId": "msg-1", "contentType": "application/json"},
"data": json.dumps(payload),
}
_run(channel._on_ws_message(data))
await 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
def test_callback_group_message_mention(self):
async def test_callback_group_message_mention(self):
channel = self._make_channel()
channel._enqueue_raw = AsyncMock()
@@ -181,12 +180,12 @@ class TestDingTalkWsMessageParsing:
"headers": {"messageId": "msg-2"},
"data": json.dumps(payload),
}
_run(channel._on_ws_message(data))
await 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
def test_callback_group_no_mention(self):
async def test_callback_group_no_mention(self):
channel = self._make_channel()
channel._enqueue_raw = AsyncMock()
@@ -201,12 +200,12 @@ class TestDingTalkWsMessageParsing:
"headers": {"messageId": "msg-3"},
"data": json.dumps(payload),
}
_run(channel._on_ws_message(data))
await 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
def test_ignores_non_callback(self):
async def test_ignores_non_callback(self):
channel = self._make_channel()
channel._enqueue_raw = AsyncMock()
@@ -215,10 +214,10 @@ class TestDingTalkWsMessageParsing:
"headers": {"messageId": "msg-x"},
"data": "{}",
}
_run(channel._on_ws_message(data))
await channel._on_ws_message(data)
channel._enqueue_raw.assert_not_called()
def test_ignores_empty_content(self):
async def test_ignores_empty_content(self):
channel = self._make_channel()
channel._enqueue_raw = AsyncMock()
@@ -232,20 +231,20 @@ class TestDingTalkWsMessageParsing:
"headers": {"messageId": "msg-e"},
"data": json.dumps(payload),
}
_run(channel._on_ws_message(data))
await channel._on_ws_message(data)
channel._enqueue_raw.assert_not_called()
def test_non_dict_data_ignored(self):
async def test_non_dict_data_ignored(self):
channel = self._make_channel()
channel._enqueue_raw = AsyncMock()
_run(channel._on_ws_message("not a dict"))
await channel._on_ws_message("not a dict")
channel._enqueue_raw.assert_not_called()
class TestDingTalkSendChunk:
"""Test _send_chunk with mocked HTTP client."""
def test_send_chunk_calls_api(self):
async 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"
@@ -256,7 +255,7 @@ class TestDingTalkSendChunk:
channel._http_client = MagicMock()
channel._http_client.post = AsyncMock(return_value=mock_response)
_run(channel._send_chunk("user1", "formatted", "raw text", None, {}))
await 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")
@@ -273,21 +272,21 @@ class TestDingTalkChannelRegistration:
class TestDingTalkProbe:
def test_missing_credentials(self):
async def test_missing_credentials(self):
from EvoScientist.channels.dingtalk.probe import validate_dingtalk
ok, msg = _run(validate_dingtalk("", ""))
ok, msg = await validate_dingtalk("", "")
assert ok is False
assert "required" in msg
def test_missing_client_id(self):
async def test_missing_client_id(self):
from EvoScientist.channels.dingtalk.probe import validate_dingtalk
ok, _msg = _run(validate_dingtalk("", "secret"))
ok, _msg = await validate_dingtalk("", "secret")
assert ok is False
def test_missing_client_secret(self):
async def test_missing_client_secret(self):
from EvoScientist.channels.dingtalk.probe import validate_dingtalk
ok, _msg = _run(validate_dingtalk("id", ""))
ok, _msg = await validate_dingtalk("id", "")
assert ok is False
+6 -7
View File
@@ -4,7 +4,6 @@ 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:
@@ -14,18 +13,18 @@ class TestDiscordChannel:
assert channel.config is config
assert channel._running is False
def test_start_raises_without_token_or_library(self):
async def test_start_raises_without_token_or_library(self):
config = DiscordConfig(bot_token="")
channel = DiscordChannel(config)
with pytest.raises(ChannelError):
_run(channel.start())
await channel.start()
def test_stop_when_not_running(self):
async def test_stop_when_not_running(self):
config = DiscordConfig(bot_token="test")
channel = DiscordChannel(config)
_run(channel.stop())
await channel.stop()
def test_send_returns_false_without_client(self):
async def test_send_returns_false_without_client(self):
from EvoScientist.channels.base import OutboundMessage
config = DiscordConfig(bot_token="test")
@@ -36,5 +35,5 @@ class TestDiscordChannel:
content="hello",
metadata={"chat_id": "123"},
)
result = _run(channel.send(msg))
result = await channel.send(msg)
assert result is False
+8 -10
View File
@@ -2,8 +2,6 @@
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
@@ -31,7 +29,7 @@ _INDEX = [
class TestInstallSkills:
def test_picker_cancel_no_install(self):
async def test_picker_cancel_no_install(self):
from EvoScientist.commands.implementation.skills import InstallSkills
ctx, ui = _ctx()
@@ -45,12 +43,12 @@ class TestInstallSkills:
"EvoScientist.tools.skills_manager.install_skill",
) as install_mock,
):
_run(InstallSkills().execute(ctx, []))
await 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)
def test_picker_returns_selections_installs_each(self):
async def test_picker_returns_selections_installs_each(self):
from EvoScientist.commands.implementation.skills import InstallSkills
ctx, ui = _ctx()
@@ -68,10 +66,10 @@ class TestInstallSkills:
return_value={"success": True, "name": "x"},
) as install_mock,
):
_run(InstallSkills().execute(ctx, []))
await InstallSkills().execute(ctx, [])
assert install_mock.call_count == 2
def test_channel_auto_install_on_tag(self):
async def test_channel_auto_install_on_tag(self):
"""Non-interactive UI + tag arg → auto-installs matching skills."""
from EvoScientist.commands.implementation.skills import InstallSkills
@@ -86,12 +84,12 @@ class TestInstallSkills:
return_value={"success": True, "name": "x"},
) as install_mock,
):
_run(InstallSkills().execute(ctx, ["core"]))
await 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()
def test_fetch_failure_prints_error(self):
async def test_fetch_failure_prints_error(self):
from EvoScientist.commands.implementation.skills import InstallSkills
ctx, ui = _ctx()
@@ -99,6 +97,6 @@ class TestInstallSkills:
"EvoScientist.tools.skills_manager.fetch_remote_skill_index",
side_effect=RuntimeError("network fail"),
):
_run(InstallSkills().execute(ctx, []))
await 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)
+2 -4
View File
@@ -2,11 +2,9 @@
from unittest.mock import MagicMock
from tests.conftest import run_async as _run
class TestExitCommand:
def test_execute_calls_force_quit(self):
async def test_execute_calls_force_quit(self):
from EvoScientist.commands.base import CommandContext
from EvoScientist.commands.implementation.session import ExitCommand
@@ -17,7 +15,7 @@ class TestExitCommand:
ui=ui,
)
cmd = ExitCommand()
_run(cmd.execute(ctx, []))
await cmd.execute(ctx, [])
ui.force_quit.assert_called_once()
def test_aliases_registered(self):
+38 -39
View File
@@ -14,7 +14,6 @@ from EvoScientist.channels.feishu.channel import (
_parse_inline_elements,
_parse_inline_text,
)
from tests.conftest import run_async as _run
class TestFeishuConfig:
@@ -57,24 +56,24 @@ class TestFeishuChannel:
assert channel._running is False
assert channel.name == "feishu"
def test_start_raises_without_app_id(self):
async 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"):
_run(channel.start())
await channel.start()
def test_start_raises_without_app_secret(self):
async 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"):
_run(channel.start())
await channel.start()
def test_stop_when_not_running(self):
async def test_stop_when_not_running(self):
config = FeishuConfig(app_id="test-id", app_secret="test-secret")
channel = FeishuChannel(config)
_run(channel.stop())
await channel.stop()
def test_send_returns_false_without_client(self):
async def test_send_returns_false_without_client(self):
config = FeishuConfig(app_id="test-id", app_secret="test-secret")
channel = FeishuChannel(config)
msg = OutboundMessage(
@@ -83,7 +82,7 @@ class TestFeishuChannel:
content="hello",
metadata={"chat_id": "oc_test"},
)
result = _run(channel.send(msg))
result = await channel.send(msg)
assert result is False
def test_capabilities(self):
@@ -201,7 +200,7 @@ class TestFeishuWebhookEvent:
channel._enqueue_raw = AsyncMock()
return channel
def test_text_message_v2(self):
async def test_text_message_v2(self):
channel = self._make_channel()
event = {
"sender": {
@@ -217,7 +216,7 @@ class TestFeishuWebhookEvent:
"create_time": "1700000000000",
},
}
_run(channel._on_message(event))
await channel._on_message(event)
channel._enqueue_raw.assert_called_once()
raw = channel._enqueue_raw.call_args[0][0]
assert raw.text == "hello feishu"
@@ -225,7 +224,7 @@ class TestFeishuWebhookEvent:
assert raw.chat_id == "oc_chat1"
assert raw.is_group is False
def test_group_message_with_mention(self):
async def test_group_message_with_mention(self):
channel = self._make_channel()
event = {
"sender": {
@@ -242,13 +241,13 @@ class TestFeishuWebhookEvent:
"mentions": [{"key": "@_user_1", "id": {}}],
},
}
_run(channel._on_message(event))
await 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"]
def test_group_message_no_mention(self):
async def test_group_message_no_mention(self):
channel = self._make_channel()
event = {
"sender": {
@@ -264,12 +263,12 @@ class TestFeishuWebhookEvent:
"create_time": "1700000000000",
},
}
_run(channel._on_message(event))
await channel._on_message(event)
raw = channel._enqueue_raw.call_args[0][0]
assert raw.is_group is True
assert raw.was_mentioned is False
def test_skips_bot_messages(self):
async def test_skips_bot_messages(self):
channel = self._make_channel()
event = {
"sender": {
@@ -283,10 +282,10 @@ class TestFeishuWebhookEvent:
"content": json.dumps({"text": "bot reply"}),
},
}
_run(channel._on_message(event))
await channel._on_message(event)
channel._enqueue_raw.assert_not_called()
def test_post_message(self):
async def test_post_message(self):
channel = self._make_channel()
post_content = {
"zh_cn": {
@@ -308,12 +307,12 @@ class TestFeishuWebhookEvent:
"create_time": "1700000000000",
},
}
_run(channel._on_message(event))
await channel._on_message(event)
raw = channel._enqueue_raw.call_args[0][0]
assert "Test" in raw.text
assert "Post body" in raw.text
def test_unsupported_msg_type_annotation(self):
async def test_unsupported_msg_type_annotation(self):
channel = self._make_channel()
event = {
"sender": {
@@ -329,7 +328,7 @@ class TestFeishuWebhookEvent:
"create_time": "1700000000000",
},
}
_run(channel._on_message(event))
await channel._on_message(event)
raw = channel._enqueue_raw.call_args[0][0]
assert "share_chat" in raw.text
@@ -337,7 +336,7 @@ class TestFeishuWebhookEvent:
class TestFeishuSendChunk:
"""Test _send_chunk with mocked HTTP client."""
def test_send_chunk_post_format(self):
async 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"
@@ -348,14 +347,14 @@ class TestFeishuSendChunk:
channel._http_client = MagicMock()
channel._http_client.post = AsyncMock(return_value=mock_response)
_run(channel._send_chunk("oc_chat1", "formatted", "raw **text**", None, {}))
await 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"
def test_send_chunk_with_reply(self):
async 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"
@@ -366,7 +365,7 @@ class TestFeishuSendChunk:
channel._http_client = MagicMock()
channel._http_client.post = AsyncMock(return_value=mock_response)
_run(channel._send_chunk("oc_chat1", "reply", "reply text", "om_reply_id", {}))
await 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
@@ -484,17 +483,17 @@ class TestFeishuChannelRegistration:
class TestFeishuProbe:
def test_missing_app_id(self):
async def test_missing_app_id(self):
from EvoScientist.channels.feishu.probe import validate_feishu_credentials
ok, msg = _run(validate_feishu_credentials("", "secret"))
ok, msg = await validate_feishu_credentials("", "secret")
assert ok is False
assert "app_id" in msg
def test_missing_app_secret(self):
async def test_missing_app_secret(self):
from EvoScientist.channels.feishu.probe import validate_feishu_credentials
ok, msg = _run(validate_feishu_credentials("id", ""))
ok, msg = await validate_feishu_credentials("id", "")
assert ok is False
assert "app_secret" in msg
@@ -510,7 +509,7 @@ class TestFeishuWebSocketMode:
)
assert config.subscription_mode == "websocket"
def test_start_websocket_raises_without_lark_oapi(self):
async def test_start_websocket_raises_without_lark_oapi(self):
config = FeishuConfig(
app_id="test-id",
app_secret="test-secret",
@@ -520,9 +519,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"):
_run(channel.start())
await channel.start()
def test_start_webhook_mode_still_works(self):
async def test_start_webhook_mode_still_works(self):
"""Ensure subscription_mode='webhook' still validates as before."""
config = FeishuConfig(
app_id="",
@@ -531,9 +530,9 @@ class TestFeishuWebSocketMode:
)
channel = FeishuChannel(config)
with pytest.raises(ChannelError, match="app_id"):
_run(channel.start())
await channel.start()
def test_invalid_subscription_mode_raises(self):
async def test_invalid_subscription_mode_raises(self):
config = FeishuConfig(
app_id="test-id",
app_secret="test-secret",
@@ -541,9 +540,9 @@ class TestFeishuWebSocketMode:
)
channel = FeishuChannel(config)
with pytest.raises(ChannelError, match="Invalid feishu_subscription_mode"):
_run(channel.start())
await channel.start()
def test_on_lark_sdk_message_bridges_to_on_message(self):
async 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
@@ -594,14 +593,14 @@ class TestFeishuWebSocketMode:
)
# Verify the consumer processes it correctly
_run(channel._on_message(event_dict))
await 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
def test_cleanup_websocket_mode(self):
async def test_cleanup_websocket_mode(self):
config = FeishuConfig(
app_id="test-id",
app_secret="test-secret",
@@ -617,7 +616,7 @@ class TestFeishuWebSocketMode:
channel._ws_consumer_task = None
channel._access_token = "fake-token"
_run(channel._cleanup())
await channel._cleanup()
mock_client.aclose.assert_called_once()
assert channel._http_client is None
+31 -41
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from unittest.mock import MagicMock
@@ -179,7 +178,7 @@ def test_launch_background_run_deletes_thread_when_run_creation_fails(monkeypatc
fake_client.threads.delete.assert_called_once_with("thread-1")
def test_async_launch_background_run_deletes_thread_when_run_creation_fails(
async def test_async_launch_background_run_deletes_thread_when_run_creation_fails(
monkeypatch,
):
monkeypatch.setattr(
@@ -204,11 +203,8 @@ def test_async_launch_background_run_deletes_thread_when_run_creation_fails(
lambda **_kwargs: SimpleNamespace(threads=_Threads(), runs=_Runs()),
)
async def run() -> None:
with pytest.raises(RuntimeError, match="run creation failed"):
await background_runs.alaunch_background_run(_request())
asyncio.run(run())
with pytest.raises(RuntimeError, match="run creation failed"):
await background_runs.alaunch_background_run(_request())
assert deleted == ["thread-1"]
@@ -278,7 +274,7 @@ def test_sync_status_watcher_preserves_thread_on_poll_failure(
assert deleted == []
def test_async_status_watcher_aborts_and_deletes_thread_on_error_status():
async 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] = []
@@ -291,29 +287,26 @@ def test_async_status_watcher_aborts_and_deletes_thread_on_error_status():
async def delete(self, thread_id: str):
deleted.append(thread_id)
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())
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,
),
)
assert finished == []
assert [run.run_id for run in aborted] == ["run-1"]
assert deleted == ["thread-1"]
def test_async_status_watcher_preserves_run_url():
async def test_async_status_watcher_preserves_run_url():
finished: list[background_runs.BackgroundRun] = []
class _Runs:
@@ -324,21 +317,18 @@ def test_async_status_watcher_preserves_run_url():
async def delete(self, _thread_id: str):
return None
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())
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,
),
)
assert [run.url for run in finished] == ["http://worker.example"]
+66 -75
View File
@@ -20,7 +20,6 @@ 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,
@@ -30,7 +29,7 @@ from tests.fakes import (
)
def test_local_gateway_streams_from_injected_streamer():
async def test_local_gateway_streams_from_injected_streamer():
seen: dict[str, Any] = {}
async def _streamer(agent, message, thread_id, **kwargs):
@@ -60,7 +59,7 @@ 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 = run_async(_collect())
events = await _collect()
assert events == [
{"type": "text", "content": "hi"},
@@ -75,7 +74,7 @@ def test_local_gateway_streams_from_injected_streamer():
}
def test_local_graph_gateway_delegates_thread_operations():
async def test_local_graph_gateway_delegates_thread_operations():
thread_store = FakeThreadStore(
generated_thread_id="new12345",
threads=[{"thread_id": "abc12345"}],
@@ -102,7 +101,7 @@ def test_local_graph_gateway_delegates_thread_operations():
"deleted": await gateway.delete_thread("abc12345"),
}
result = run_async(_run())
result = await _run()
assert result["created"] == "new12345"
assert result["threads"] == [{"thread_id": "abc12345"}]
@@ -132,16 +131,14 @@ def test_local_graph_gateway_delegates_thread_operations():
]
def test_local_graph_gateway_reads_state_values():
async 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 = run_async(
gateway.get_state_values(GraphTarget(local_graph=agent), "abc12345")
)
values = await gateway.get_state_values(GraphTarget(local_graph=agent), "abc12345")
assert values == {"async_tasks": {"task-1": {}}}
agent.aget_state.assert_awaited_once_with(
@@ -149,17 +146,15 @@ def test_local_graph_gateway_reads_state_values():
)
def test_local_graph_gateway_updates_state_values():
async def test_local_graph_gateway_updates_state_values():
agent = MagicMock()
agent.aupdate_state = AsyncMock()
gateway = LocalGraphGateway()
run_async(
gateway.update_state_values(
GraphTarget(local_graph=agent),
"abc12345",
{"_summarization_event": {"cutoff_index": 2}},
)
await gateway.update_state_values(
GraphTarget(local_graph=agent),
"abc12345",
{"_summarization_event": {"cutoff_index": 2}},
)
agent.aupdate_state.assert_awaited_once_with(
@@ -169,7 +164,7 @@ def test_local_graph_gateway_updates_state_values():
)
def test_local_stream_events_delegates_aclose_to_inner():
async def test_local_stream_events_delegates_aclose_to_inner():
cleanup_ran = False
async def _streamer(_agent, _message, _thread_id, **_kwargs):
@@ -194,7 +189,7 @@ def test_local_stream_events_delegates_aclose_to_inner():
assert cleanup_ran is True
with patch("EvoScientist.stream.events.stream_agent_events", new=_streamer):
run_async(_run())
await _run()
def test_run_streaming_can_consume_injected_gateway():
@@ -228,7 +223,7 @@ def test_run_streaming_can_consume_injected_gateway():
]
def test_resume_command_consumes_context_gateway():
async def test_resume_command_consumes_context_gateway():
from EvoScientist.commands.base import CommandContext
from EvoScientist.commands.implementation.session import ResumeCommand
@@ -246,7 +241,7 @@ def test_resume_command_consumes_context_gateway():
graph_gateway=FakeGraphGateway(thread_store=thread_store),
)
run_async(ResumeCommand().execute(ctx, ["abc"]))
await ResumeCommand().execute(ctx, ["abc"])
assert ctx.thread_id == "abc12345"
assert ctx.workspace_dir == "/restored"
@@ -287,7 +282,7 @@ def test_cmd_run_passes_local_graph_gateway(monkeypatch):
assert seen["gateway"].thread_store is thread_store
def test_langgraph_server_thread_store_delegates_to_sdk_threads():
async def test_langgraph_server_thread_store_delegates_to_sdk_threads():
threads = FakeLangGraphThreadsClient(
threads=[
{
@@ -335,7 +330,7 @@ def test_langgraph_server_thread_store_delegates_to_sdk_threads():
"deleted": await store.delete_thread("abc12345"),
}
result = run_async(_run())
result = await _run()
assert result["created"] == "server-thread"
assert len(threads.created) == 1
@@ -379,7 +374,7 @@ def test_langgraph_server_thread_store_delegates_to_sdk_threads():
assert threads.deleted == ["abc12345"]
def test_langgraph_server_thread_store_limit_zero_pages_all_threads():
async def test_langgraph_server_thread_store_limit_zero_pages_all_threads():
rows = [
{
"thread_id": f"thread-{index}",
@@ -392,7 +387,7 @@ def test_langgraph_server_thread_store_limit_zero_pages_all_threads():
client=FakeLangGraphClient(threads),
)
result = run_async(store.list_threads(limit=0))
result = await store.list_threads(limit=0)
assert [row["thread_id"] for row in result] == [
f"thread-{index}" for index in range(_THREAD_SEARCH_LIMIT + 1)
@@ -403,7 +398,7 @@ def test_langgraph_server_thread_store_limit_zero_pages_all_threads():
]
def test_langgraph_server_thread_store_positive_limit_uses_single_search():
async def test_langgraph_server_thread_store_positive_limit_uses_single_search():
threads = FakeLangGraphThreadsClient(
threads=[
{
@@ -417,7 +412,7 @@ def test_langgraph_server_thread_store_positive_limit_uses_single_search():
client=FakeLangGraphClient(threads),
)
result = run_async(store.list_threads(limit=2))
result = await 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] == [
@@ -425,7 +420,7 @@ def test_langgraph_server_thread_store_positive_limit_uses_single_search():
]
def test_langgraph_server_thread_store_prefix_resolution_skips_exact_lookup():
async def test_langgraph_server_thread_store_prefix_resolution_skips_exact_lookup():
threads = FakeLangGraphThreadsClient(
threads=[
{
@@ -438,14 +433,14 @@ def test_langgraph_server_thread_store_prefix_resolution_skips_exact_lookup():
client=FakeLangGraphClient(threads),
)
result = run_async(store.resolve_thread_id_prefix("abc"))
result = await store.resolve_thread_id_prefix("abc")
assert result == ("abc12345", [])
assert threads.gets == []
assert len(threads.searches) == 1
def test_langgraph_server_thread_store_prefix_resolution_pages_all_threads():
async def test_langgraph_server_thread_store_prefix_resolution_pages_all_threads():
rows = [
{
"thread_id": f"thread-{index}",
@@ -464,7 +459,7 @@ def test_langgraph_server_thread_store_prefix_resolution_pages_all_threads():
client=FakeLangGraphClient(threads),
)
result = run_async(store.resolve_thread_id_prefix("older-thread"))
result = await store.resolve_thread_id_prefix("older-thread")
assert result == ("older-thread-match", [])
assert [(search["limit"], search["offset"]) for search in threads.searches] == [
@@ -473,7 +468,7 @@ def test_langgraph_server_thread_store_prefix_resolution_pages_all_threads():
]
def test_langgraph_server_thread_store_uuid_resolution_uses_exact_lookup():
async def test_langgraph_server_thread_store_uuid_resolution_uses_exact_lookup():
thread_id = "019ed9e4-4253-7f62-b50f-f0470a4b3c9f"
threads = FakeLangGraphThreadsClient(
threads=[
@@ -487,14 +482,14 @@ def test_langgraph_server_thread_store_uuid_resolution_uses_exact_lookup():
client=FakeLangGraphClient(threads),
)
result = run_async(store.resolve_thread_id_prefix(thread_id))
result = await store.resolve_thread_id_prefix(thread_id)
assert result == (thread_id, [])
assert threads.gets == [thread_id]
assert threads.searches == []
def test_langgraph_server_thread_store_uuid_resolution_filters_graph_id():
async def test_langgraph_server_thread_store_uuid_resolution_filters_graph_id():
thread_id = "019ed9e4-4253-7f62-b50f-f0470a4b3c9f"
threads = FakeLangGraphThreadsClient(
threads=[
@@ -508,7 +503,7 @@ def test_langgraph_server_thread_store_uuid_resolution_filters_graph_id():
client=FakeLangGraphClient(threads),
)
result = run_async(store.resolve_thread_id_prefix(thread_id))
result = await store.resolve_thread_id_prefix(thread_id)
assert result == (None, [])
assert threads.gets == [thread_id]
@@ -517,7 +512,7 @@ def test_langgraph_server_thread_store_uuid_resolution_filters_graph_id():
]
def test_langgraph_server_thread_store_clones_thread_with_metadata():
async def test_langgraph_server_thread_store_clones_thread_with_metadata():
clone_metadata = {
"clone_purpose": "memory_extraction",
"source_thread_id": "source-thread",
@@ -534,8 +529,8 @@ def test_langgraph_server_thread_store_clones_thread_with_metadata():
client=FakeLangGraphClient(threads),
)
cloned_thread_id = run_async(
store.clone_thread("source-thread", metadata=clone_metadata)
cloned_thread_id = await store.clone_thread(
"source-thread", metadata=clone_metadata
)
assert cloned_thread_id == "source-thread-copy"
@@ -552,7 +547,7 @@ def test_langgraph_server_thread_store_clones_thread_with_metadata():
}
def test_langgraph_server_thread_store_rejects_copy_without_thread_id():
async 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,
@@ -565,10 +560,10 @@ 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"):
run_async(_run())
await _run()
def test_langgraph_server_gateway_clones_thread():
async def test_langgraph_server_gateway_clones_thread():
threads = FakeLangGraphThreadsClient(
threads=[{"thread_id": "source-thread", "metadata": {"graph_id": "agent"}}]
)
@@ -578,12 +573,10 @@ def test_langgraph_server_gateway_clones_thread():
)
)
cloned_thread_id = run_async(
gateway.clone_thread(
"source-thread",
metadata={"clone_purpose": "manual"},
target=GraphTarget(graph_id="agent"),
)
cloned_thread_id = await gateway.clone_thread(
"source-thread",
metadata={"clone_purpose": "manual"},
target=GraphTarget(graph_id="agent"),
)
assert cloned_thread_id == "source-thread-copy"
@@ -592,12 +585,12 @@ def test_langgraph_server_gateway_clones_thread():
]
def test_local_graph_gateway_clone_thread_is_explicitly_unsupported():
async 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"):
run_async(_run())
await _run()
def test_runtime_gateways_can_use_langgraph_server_backend():
@@ -616,7 +609,7 @@ def test_runtime_gateways_can_use_langgraph_server_backend():
assert gateway.thread_store is runtime_gateways.thread_store
def test_langgraph_server_gateway_reads_state_values():
async 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": {}}}}},
@@ -627,12 +620,12 @@ def test_langgraph_server_gateway_reads_state_values():
)
)
values = run_async(gateway.get_state_values(GraphTarget(), "abc12345"))
values = await gateway.get_state_values(GraphTarget(), "abc12345")
assert values == {"async_tasks": {"task-1": {}}}
def test_langgraph_server_gateway_messages_apply_summarization_event():
async def test_langgraph_server_gateway_messages_apply_summarization_event():
threads = FakeLangGraphThreadsClient(
threads=[{"thread_id": "abc12345", "metadata": {"graph_id": "EvoScientist"}}],
states={
@@ -658,7 +651,7 @@ def test_langgraph_server_gateway_messages_apply_summarization_event():
)
)
messages = run_async(gateway.get_thread_messages("abc12345"))
messages = await gateway.get_thread_messages("abc12345")
assert len(messages) == 2
assert isinstance(messages[0], AIMessage)
@@ -667,7 +660,7 @@ def test_langgraph_server_gateway_messages_apply_summarization_event():
assert messages[1].content == "third"
def test_langgraph_server_gateway_updates_state_values():
async def test_langgraph_server_gateway_updates_state_values():
threads = FakeLangGraphThreadsClient(
threads=[{"thread_id": "abc12345", "metadata": {"graph_id": "EvoScientist"}}],
)
@@ -677,12 +670,10 @@ def test_langgraph_server_gateway_updates_state_values():
)
)
run_async(
gateway.update_state_values(
GraphTarget(),
"abc12345",
{"_summarization_event": {"cutoff_index": 2}},
)
await gateway.update_state_values(
GraphTarget(),
"abc12345",
{"_summarization_event": {"cutoff_index": 2}},
)
assert threads.state_updates == [
@@ -690,7 +681,7 @@ def test_langgraph_server_gateway_updates_state_values():
]
def test_langgraph_server_gateway_streams_root_protocol_events():
async def test_langgraph_server_gateway_streams_root_protocol_events():
stream = FakeLangGraphThreadStream(
"abc12345",
events=[
@@ -737,7 +728,7 @@ def test_langgraph_server_gateway_streams_root_protocol_events():
)
]
events = run_async(_collect())
events = await _collect()
assert len(threads.created) == 1
assert threads.created[0]["thread_id"] == "abc12345"
@@ -804,7 +795,7 @@ def _root_message_finish() -> dict[str, object]:
}
def _collect_server_gateway_stream(
async def _collect_server_gateway_stream(
events: list[dict[str, object]],
*,
state_messages: list[dict[str, object]] | None = None,
@@ -832,11 +823,11 @@ def _collect_server_gateway_stream(
)
]
return run_async(_collect())
return await _collect()
def test_langgraph_server_gateway_streams_value_message_snapshots():
events = _collect_server_gateway_stream(
async def test_langgraph_server_gateway_streams_value_message_snapshots():
events = await _collect_server_gateway_stream(
[
_value_snapshot([_OLD_AI, _HUMAN]),
_value_snapshot([_OLD_AI, _HUMAN, _NEW_AI]),
@@ -850,8 +841,8 @@ def test_langgraph_server_gateway_streams_value_message_snapshots():
]
def test_langgraph_server_gateway_values_do_not_duplicate_message_stream():
events = _collect_server_gateway_stream(
async def test_langgraph_server_gateway_values_do_not_duplicate_message_stream():
events = await _collect_server_gateway_stream(
[
_root_text_delta("new"),
_root_message_finish(),
@@ -866,8 +857,8 @@ def test_langgraph_server_gateway_values_do_not_duplicate_message_stream():
]
def test_langgraph_server_gateway_ignores_non_root_value_messages():
events = _collect_server_gateway_stream(
async def test_langgraph_server_gateway_ignores_non_root_value_messages():
events = await _collect_server_gateway_stream(
[
_value_snapshot(
[{"type": "ai", "content": "subagent text", "id": "subagent-ai"}],
@@ -880,7 +871,7 @@ def test_langgraph_server_gateway_ignores_non_root_value_messages():
assert events[-1] == {"type": "done", "content": "", "response": ""}
def test_langgraph_server_gateway_emits_state_interrupt_before_done():
async def test_langgraph_server_gateway_emits_state_interrupt_before_done():
stream = FakeLangGraphThreadStream(
"abc12345",
events=[],
@@ -930,7 +921,7 @@ def test_langgraph_server_gateway_emits_state_interrupt_before_done():
)
]
events = run_async(_collect())
events = await _collect()
assert events == [
{
@@ -954,7 +945,7 @@ def test_langgraph_server_gateway_emits_state_interrupt_before_done():
]
def test_langgraph_server_gateway_streams_subagent_protocol_events():
async def test_langgraph_server_gateway_streams_subagent_protocol_events():
stream = FakeLangGraphThreadStream(
"abc12345",
events=[
@@ -1003,7 +994,7 @@ def test_langgraph_server_gateway_streams_subagent_protocol_events():
)
]
events = run_async(_collect())
events = await _collect()
assert events == [
{
@@ -1028,7 +1019,7 @@ def test_langgraph_server_gateway_streams_subagent_protocol_events():
]
def test_langgraph_server_gateway_resumes_interrupt_with_thread_stream():
async def test_langgraph_server_gateway_resumes_interrupt_with_thread_stream():
from langgraph.types import Command
stream = FakeLangGraphThreadStream(
@@ -1058,7 +1049,7 @@ def test_langgraph_server_gateway_resumes_interrupt_with_thread_stream():
)
]
events = run_async(_collect())
events = await _collect()
assert stream.run.starts == []
assert stream.run.responses == [
+4 -4
View File
@@ -378,7 +378,7 @@ class TestHitlConfig:
class TestInterruptEventParsing:
def test_interrupt_from_updates_mode(self):
async 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 = collect_events(agent, message="test", thread_id="thread-1")
events = await 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"
def test_updates_without_interrupt_skipped(self):
async 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 = collect_events(agent, message="test", thread_id="thread-1")
events = await collect_events(agent, message="test", thread_id="thread-1")
types = [e["type"] for e in events]
assert "interrupt" not in types
+10 -12
View File
@@ -2,8 +2,6 @@
from unittest.mock import MagicMock, patch
from tests.conftest import run_async as _run
def _ctx():
from EvoScientist.commands.base import CommandContext
@@ -14,15 +12,15 @@ def _ctx():
class TestInstallSkill:
def test_usage_message_when_no_args(self):
async def test_usage_message_when_no_args(self):
from EvoScientist.commands.implementation.skills import InstallSkill
ctx, ui = _ctx()
_run(InstallSkill().execute(ctx, []))
await 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)
def test_happy_path(self):
async def test_happy_path(self):
from EvoScientist.commands.implementation.skills import InstallSkill
ctx, ui = _ctx()
@@ -35,21 +33,21 @@ class TestInstallSkill:
"path": "/tmp/demo",
},
):
_run(InstallSkill().execute(ctx, ["./some-path"]))
await 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:
def test_usage_message_when_no_args(self):
async def test_usage_message_when_no_args(self):
from EvoScientist.commands.implementation.skills import UninstallSkill
ctx, ui = _ctx()
_run(UninstallSkill().execute(ctx, []))
await 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)
def test_uninstall_success(self):
async def test_uninstall_success(self):
from EvoScientist.commands.implementation.skills import UninstallSkill
ctx, ui = _ctx()
@@ -57,11 +55,11 @@ class TestUninstallSkill:
"EvoScientist.tools.skills_manager.uninstall_skill",
return_value={"success": True},
):
_run(UninstallSkill().execute(ctx, ["demo-skill"]))
await 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)
def test_uninstall_failure(self):
async def test_uninstall_failure(self):
from EvoScientist.commands.implementation.skills import UninstallSkill
ctx, ui = _ctx()
@@ -69,6 +67,6 @@ class TestUninstallSkill:
"EvoScientist.tools.skills_manager.uninstall_skill",
return_value={"success": False, "error": "not found"},
):
_run(UninstallSkill().execute(ctx, ["missing"]))
await 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
def test_writes_each_event_as_one_jsonl_line(run_async):
async def test_writes_each_event_as_one_jsonl_line():
"""Each event dict is serialized to exactly one JSON line, in order."""
events = [
{"type": "thinking", "content": "hmm", "id": 0},
@@ -34,7 +34,7 @@ def test_writes_each_event_as_one_jsonl_line(run_async):
]
out = io.StringIO()
run_async(write_events_as_json(_agen(events), out))
await write_events_as_json(_agen(events), out)
lines = out.getvalue().splitlines()
assert len(lines) == len(events)
@@ -43,7 +43,7 @@ def test_writes_each_event_as_one_jsonl_line(run_async):
assert parsed[2]["args"] == {"path": "a.md"}
def test_returns_final_response_from_done_event(run_async):
async def test_returns_final_response_from_done_event():
"""The sink returns the response text carried by the terminal `done` event."""
events = [
{"type": "text", "content": "partial"},
@@ -51,12 +51,12 @@ def test_returns_final_response_from_done_event(run_async):
]
out = io.StringIO()
result = run_async(write_events_as_json(_agen(events), out))
result = await write_events_as_json(_agen(events), out)
assert result == "the answer"
def test_non_serializable_arg_does_not_crash_the_stream(run_async):
async def test_non_serializable_arg_does_not_crash_the_stream():
"""A non-JSON-serializable value degrades to its str form instead of raising."""
class Weird:
@@ -72,7 +72,7 @@ def test_non_serializable_arg_does_not_crash_the_stream(run_async):
]
out = io.StringIO()
run_async(write_events_as_json(_agen(events), out))
await 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 @@ def test_non_serializable_arg_does_not_crash_the_stream(run_async):
assert first["args"]["obj"] == "WEIRD"
def test_stream_json_sources_events_from_gateway(run_async):
async def test_stream_json_sources_events_from_gateway():
"""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 @@ def test_stream_json_sources_events_from_gateway(run_async):
return _agen(events)
out = io.StringIO()
result = run_async(stream_json(_FakeGateway(), object(), out=out))
result = await stream_json(_FakeGateway(), object(), out=out)
assert result == "hi"
assert "request" in seen # the request was forwarded to the gateway
@@ -106,7 +106,7 @@ def test_stream_json_sources_events_from_gateway(run_async):
assert types == ["text", "done"]
def test_stream_json_propagates_gateway_errors(run_async):
async def test_stream_json_propagates_gateway_errors():
"""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 @@ def test_stream_json_propagates_gateway_errors(run_async):
out = io.StringIO()
with pytest.raises(RuntimeError, match="boom"):
run_async(stream_json(_FakeGateway(), object(), out=out))
await stream_json(_FakeGateway(), object(), out=out)
+72
View File
@@ -8,6 +8,7 @@ to be available.
from __future__ import annotations
import dataclasses
import sys
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
@@ -32,6 +33,77 @@ 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
View File
@@ -972,7 +972,6 @@ 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
@@ -1003,7 +1002,6 @@ 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
@@ -1044,7 +1042,6 @@ 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
@@ -1077,7 +1074,6 @@ 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
@@ -1550,7 +1546,6 @@ 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
+10 -18
View File
@@ -1399,9 +1399,7 @@ class TestLoadToolsProgressCallback:
monkeypatch.setattr(lc_client, "MultiServerMCPClient", _FakeClient)
def test_success_emits_start_then_success_with_tool_count(self, monkeypatch):
import asyncio
async def test_success_emits_start_then_success_with_tool_count(self, monkeypatch):
from EvoScientist.mcp.client import _load_tools
events: list[tuple[str, str, str]] = []
@@ -1415,16 +1413,14 @@ class TestLoadToolsProgressCallback:
def record(event, name, detail):
events.append((event, name, detail))
asyncio.run(_load_tools(config, on_progress=record))
await _load_tools(config, on_progress=record)
assert events == [
("start", "srv", ""),
("success", "srv", "3"),
]
def test_failure_emits_start_then_error_with_detail(self, monkeypatch):
import asyncio
async def test_failure_emits_start_then_error_with_detail(self, monkeypatch):
from EvoScientist.mcp.client import _load_tools
events: list[tuple[str, str, str]] = []
@@ -1435,16 +1431,14 @@ class TestLoadToolsProgressCallback:
def record(event, name, detail):
events.append((event, name, detail))
asyncio.run(_load_tools(config, on_progress=record))
await _load_tools(config, on_progress=record)
assert events == [
("start", "srv", ""),
("error", "srv", "boom"),
]
def test_mixed_fleet_reports_each_server_independently(self, monkeypatch):
import asyncio
async def test_mixed_fleet_reports_each_server_independently(self, monkeypatch):
from EvoScientist.mcp.client import _load_tools
events: list[tuple[str, str, str]] = []
@@ -1464,7 +1458,7 @@ class TestLoadToolsProgressCallback:
def record(event, name, detail):
events.append((event, name, detail))
asyncio.run(_load_tools(config, on_progress=record))
await _load_tools(config, on_progress=record)
by_server = {}
for ev, name, detail in events:
@@ -1472,9 +1466,7 @@ class TestLoadToolsProgressCallback:
assert by_server["ok_srv"] == [("start", ""), ("success", "1")]
assert by_server["bad_srv"] == [("start", ""), ("error", "refused")]
def test_callback_errors_do_not_break_the_load(self, monkeypatch):
import asyncio
async def test_callback_errors_do_not_break_the_load(self, monkeypatch):
from EvoScientist.mcp.client import _load_tools
self._patch_client(monkeypatch, {"srv": ["tool1"]})
@@ -1484,10 +1476,10 @@ class TestLoadToolsProgressCallback:
def bad_callback(event, name, detail):
raise RuntimeError("callback bug")
result = asyncio.run(_load_tools(config, on_progress=bad_callback))
result = await _load_tools(config, on_progress=bad_callback)
assert result == {"srv": ["tool1"]}
def test_semaphore_caps_concurrent_connections(self, monkeypatch):
async def test_semaphore_caps_concurrent_connections(self, monkeypatch):
"""Many configured servers must not all spawn at once."""
import asyncio
@@ -1516,7 +1508,7 @@ class TestLoadToolsProgressCallback:
config = {
f"srv{i}": {"transport": "stdio", "command": "demo"} for i in range(10)
}
asyncio.run(mcp_client._load_tools(config))
await mcp_client._load_tools(config)
assert inflight["peak"] <= 3
assert inflight["peak"] > 1 # sanity: we *are* parallelizing
+16 -18
View File
@@ -2,8 +2,6 @@
from unittest.mock import MagicMock, patch
from tests.conftest import run_async as _run
def _ctx():
from EvoScientist.commands.base import CommandContext
@@ -14,16 +12,16 @@ def _ctx():
class TestMCPCommandDispatch:
def test_no_args_lists(self):
async 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={}):
_run(MCPCommand().execute(ctx, []))
await 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)
def test_list_subcommand(self):
async def test_list_subcommand(self):
from EvoScientist.commands.implementation.mcp import MCPCommand
ctx, ui = _ctx()
@@ -31,10 +29,10 @@ class TestMCPCommandDispatch:
"srv1": {"transport": "stdio", "tools": ["foo"], "expose_to": ["main"]},
}
with patch("EvoScientist.mcp.load_mcp_config", return_value=cfg):
_run(MCPCommand().execute(ctx, ["list"]))
await MCPCommand().execute(ctx, ["list"])
ui.mount_renderable.assert_called_once()
def test_add_subcommand_dispatches(self):
async def test_add_subcommand_dispatches(self):
from EvoScientist.commands.implementation.mcp import MCPCommand
ctx, _ui = _ctx()
@@ -48,10 +46,10 @@ class TestMCPCommandDispatch:
return_value={"transport": "stdio"},
) as add_mock,
):
_run(MCPCommand().execute(ctx, ["add", "srv1", "python"]))
await MCPCommand().execute(ctx, ["add", "srv1", "python"])
add_mock.assert_called_once()
def test_edit_subcommand_dispatches(self):
async def test_edit_subcommand_dispatches(self):
from EvoScientist.commands.implementation.mcp import MCPCommand
ctx, _ui = _ctx()
@@ -64,28 +62,28 @@ class TestMCPCommandDispatch:
"EvoScientist.mcp.edit_mcp_server",
) as edit_mock,
):
_run(MCPCommand().execute(ctx, ["edit", "srv1", "--tools", "bar"]))
await MCPCommand().execute(ctx, ["edit", "srv1", "--tools", "bar"])
edit_mock.assert_called_once_with("srv1", tools=["bar"])
def test_remove_subcommand_success(self):
async 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):
_run(MCPCommand().execute(ctx, ["remove", "srv1"]))
await 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)
def test_remove_subcommand_not_found(self):
async 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):
_run(MCPCommand().execute(ctx, ["remove", "missing"]))
await 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)
def test_install_delegates_to_install_mcp_command(self):
async def test_install_delegates_to_install_mcp_command(self):
"""/mcp install should instantiate InstallMCPCommand and execute it."""
from EvoScientist.commands.implementation.mcp import MCPCommand
@@ -101,13 +99,13 @@ class TestMCPCommandDispatch:
instance.execute = fake_execute
klass.return_value = instance
_run(MCPCommand().execute(ctx, ["install", "foo"]))
await MCPCommand().execute(ctx, ["install", "foo"])
klass.assert_called_once()
def test_unknown_subcommand_prints_help(self):
async def test_unknown_subcommand_prints_help(self):
from EvoScientist.commands.implementation.mcp import MCPCommand
ctx, ui = _ctx()
_run(MCPCommand().execute(ctx, ["bogus"]))
await 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,8 +5,6 @@ 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."""
@@ -80,7 +78,7 @@ class TestExtractModelAndProvider:
class TestModelCommandUnknownModel:
"""Verify error message for unknown models."""
def test_unknown_model_shows_error(self):
async def test_unknown_model_shows_error(self):
from EvoScientist.commands.implementation.model import ModelCommand
cmd = ModelCommand()
@@ -95,7 +93,7 @@ class TestModelCommandUnknownModel:
"EvoScientist.EvoScientist._ensure_config",
return_value=cfg,
):
_run(cmd.execute(ctx, ["nonexistent-model-xyz"]))
await cmd.execute(ctx, ["nonexistent-model-xyz"])
ui.append_system.assert_called_once()
call_args = ui.append_system.call_args
@@ -106,7 +104,7 @@ class TestModelCommandUnknownModel:
class TestModelCommandPickerCancelled:
"""Verify no-op when the interactive picker is cancelled."""
def test_picker_returns_none(self):
async def test_picker_returns_none(self):
from EvoScientist.commands.implementation.model import ModelCommand
cmd = ModelCommand()
@@ -122,7 +120,7 @@ class TestModelCommandPickerCancelled:
"EvoScientist.EvoScientist._ensure_config",
return_value=cfg,
):
_run(cmd.execute(ctx, []))
await cmd.execute(ctx, [])
# No model switch should have happened
ui.append_system.assert_not_called()
@@ -131,7 +129,7 @@ class TestModelCommandPickerCancelled:
class TestModelCommandSwitch:
"""Verify a successful model switch updates config and rebuilds agent."""
def test_switch_known_model(self):
async def test_switch_known_model(self):
from EvoScientist.commands.implementation.model import ModelCommand
cmd = ModelCommand()
@@ -158,7 +156,7 @@ class TestModelCommandSwitch:
return_value=new_agent,
),
):
_run(cmd.execute(ctx, ["claude-opus-4-8"]))
await 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.
@@ -176,7 +174,7 @@ class TestModelCommandSwitch:
assert "claude-opus-4-8" in msg
assert "anthropic" in msg
def test_switch_with_save_flag(self):
async def test_switch_with_save_flag(self):
from EvoScientist.commands.implementation.model import ModelCommand
cmd = ModelCommand()
@@ -203,7 +201,7 @@ class TestModelCommandSwitch:
),
patch("EvoScientist.config.settings.set_config_value") as mock_save,
):
_run(cmd.execute(ctx, ["claude-opus-4-8", "--save"]))
await cmd.execute(ctx, ["claude-opus-4-8", "--save"])
# Config file should be updated
mock_save.assert_any_call("model", "claude-opus-4-8")
@@ -213,7 +211,7 @@ class TestModelCommandSwitch:
msg = ui.append_system.call_args[0][0]
assert "saved to config" in msg
def test_switch_without_save_flag_does_not_persist(self):
async def test_switch_without_save_flag_does_not_persist(self):
from EvoScientist.commands.implementation.model import ModelCommand
cmd = ModelCommand()
@@ -240,7 +238,7 @@ class TestModelCommandSwitch:
),
patch("EvoScientist.config.settings.set_config_value") as mock_save,
):
_run(cmd.execute(ctx, ["claude-opus-4-8"]))
await cmd.execute(ctx, ["claude-opus-4-8"])
# Config file should NOT be updated
mock_save.assert_not_called()
@@ -253,7 +251,7 @@ class TestModelCommandSwitch:
class TestModelCommandFailure:
"""Verify error handling when chat-model construction raises."""
def test_build_chat_model_error(self):
async def test_build_chat_model_error(self):
from EvoScientist.commands.implementation.model import ModelCommand
cmd = ModelCommand()
@@ -276,7 +274,7 @@ class TestModelCommandFailure:
side_effect=RuntimeError("API key missing"),
) as mock_build,
):
_run(cmd.execute(ctx, ["claude-opus-4-8"]))
await cmd.execute(ctx, ["claude-opus-4-8"])
mock_build.assert_called_once()
ui.append_system.assert_called_once()
@@ -446,7 +444,7 @@ class TestApplyModelIntegration:
pair so we can assert on identity.
"""
def test_new_agent_is_bound_to_newly_selected_model(self, evo_module_state):
async 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
@@ -502,7 +500,7 @@ class TestApplyModelIntegration:
),
):
cmd = ModelCommand()
_run(cmd._apply_model(ctx, "minimax-m2.7", "openrouter"))
await 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=.
@@ -539,7 +537,9 @@ class TestApplyModelPreservesConfigByReference:
switch (the held object stops being the active ``_config`` after the first).
"""
def test_held_config_reference_tracks_repeated_switches(self, evo_module_state):
async 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"),
]:
_run(cmd._apply_model(ctx, model, provider))
await 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)."""
def test_load_agent_error_is_transactional(self):
async 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.
_run(cmd.execute(ctx, ["claude-opus-4-8", "--save"]))
await 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.
"""
def test_globals_unchanged_when_load_agent_raises(self, evo_module_state):
async 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()
_run(cmd._apply_model(ctx, "minimax-m2.7", "openrouter"))
await 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
def test_picker_entries_include_detected_ollama_models(self):
async 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,
),
):
_run(ModelCommand().execute(ctx, []))
await 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
def test_picker_entries_include_sentinel_when_discovery_empty(self):
async 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,
),
):
_run(ModelCommand().execute(ctx, []))
await 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")
]
def test_picker_skips_ollama_section_when_not_configured(self):
async 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,
),
):
_run(ModelCommand().execute(ctx, []))
await 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)
def test_picker_handles_cfg_without_ollama_base_url_attr(self):
async 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,
),
):
_run(ModelCommand().execute(ctx, []))
await 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)
def test_picker_sentinel_result_is_treated_as_cancel(self):
async 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,
):
_run(ModelCommand().execute(ctx, []))
await ModelCommand().execute(ctx, [])
load_agent.assert_not_called()
assert cfg.model == "claude-sonnet-4-6" # unchanged
def test_picker_applies_detected_ollama_model(self):
async 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(),
),
):
_run(ModelCommand().execute(ctx, []))
await ModelCommand().execute(ctx, [])
# Committed via set_active_config(temp_cfg); original cfg untouched.
set_cfg.assert_called_once()
+22 -27
View File
@@ -20,7 +20,6 @@ from EvoScientist.middleware.model_fallback import (
clear_fallbacks,
set_ui_emit,
)
from tests.conftest import run_async as _run
# ── Helpers ──────────────────────────────────────────────────────
@@ -146,7 +145,7 @@ class TestIsNonFallbackable:
class TestTryFallbacks:
"""End-to-end tests for the fallback chain traversal."""
def test_first_fallback_succeeds(self):
async def test_first_fallback_succeeds(self):
"""When the first fallback model works, return its response."""
add_fallback("fb-model", "fb-provider")
req = _fake_request()
@@ -154,13 +153,13 @@ class TestTryFallbacks:
with patch("EvoScientist.llm.models.get_chat_model") as mock_gcm:
mock_gcm.return_value = MagicMock()
result = _run(_try_fallbacks(req, invoke, Exception("503 boom")))
result = await _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")
def test_skips_failing_fallback_tries_next(self):
async 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")
@@ -177,12 +176,12 @@ class TestTryFallbacks:
with patch("EvoScientist.llm.models.get_chat_model") as mock_gcm:
mock_gcm.return_value = MagicMock()
result = _run(_try_fallbacks(req, _invoke, Exception("503 boom")))
result = await _try_fallbacks(req, _invoke, Exception("503 boom"))
assert result is AI_RESPONSE
assert call_count == 2
def test_all_fallbacks_exhausted_raises_last(self):
async 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")
@@ -202,11 +201,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:
_run(_try_fallbacks(req, _invoke, Exception("503 primary")))
await _try_fallbacks(req, _invoke, Exception("503 primary"))
assert exc_info.value is last_error
def test_non_fallbackable_in_chain_aborts_immediately(self):
async 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
@@ -218,7 +217,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"):
_run(_try_fallbacks(req, _invoke, Exception("503 primary")))
await _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.
@@ -233,43 +232,41 @@ class TestTryFallbacks:
class TestGuardAndFallback:
"""Verify that non-fallbackable errors are re-raised before trying the chain."""
def test_context_overflow_raises_immediately(self):
async def test_context_overflow_raises_immediately(self):
add_fallback("fb", "prov")
req = _fake_request()
invoke = AsyncMock()
with pytest.raises(ContextOverflowError):
_run(_guard_and_fallback(ContextOverflowError("overflow"), req, invoke))
await _guard_and_fallback(ContextOverflowError("overflow"), req, invoke)
invoke.assert_not_awaited()
def test_malformed_400_raises_immediately(self):
async def test_malformed_400_raises_immediately(self):
add_fallback("fb", "prov")
req = _fake_request()
invoke = AsyncMock()
with pytest.raises(Exception, match="invalid_request_error"):
_run(
_guard_and_fallback(
Exception("400: invalid_request_error"), req, invoke
)
await _guard_and_fallback(
Exception("400: invalid_request_error"), req, invoke
)
invoke.assert_not_awaited()
def test_server_error_proceeds_to_fallback(self):
async 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 = _run(_guard_and_fallback(Exception("503 overloaded"), req, invoke))
result = await _guard_and_fallback(Exception("503 overloaded"), req, invoke)
assert result is AI_RESPONSE
invoke.assert_awaited_once()
def test_auth_error_proceeds_to_fallback(self):
async 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()
@@ -277,10 +274,8 @@ class TestGuardAndFallback:
with patch("EvoScientist.llm.models.get_chat_model") as mock_gcm:
mock_gcm.return_value = MagicMock()
result = _run(
_guard_and_fallback(
Exception("400 Bad Request: invalid_api_key"), req, invoke
)
result = await _guard_and_fallback(
Exception("400 Bad Request: invalid_api_key"), req, invoke
)
assert result is AI_RESPONSE
@@ -295,7 +290,7 @@ class TestGuardAndFallback:
class TestUiEmit:
"""Verify that fallback events are surfaced via the registered callback."""
def test_emit_captures_messages(self):
async def test_emit_captures_messages(self):
add_fallback("fb", "prov")
req = _fake_request()
invoke = AsyncMock(return_value=AI_RESPONSE)
@@ -305,14 +300,14 @@ class TestUiEmit:
with patch("EvoScientist.llm.models.get_chat_model") as mock_gcm:
mock_gcm.return_value = MagicMock()
_run(_try_fallbacks(req, invoke, Exception("503 down")))
await _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)
def test_emit_shows_non_fallbackable_rejection(self):
async def test_emit_shows_non_fallbackable_rejection(self):
add_fallback("fb", "prov")
req = _fake_request()
invoke = AsyncMock()
@@ -321,7 +316,7 @@ class TestUiEmit:
set_ui_emit(lambda text, style: messages.append((text, style)))
with pytest.raises(ContextOverflowError):
_run(_guard_and_fallback(ContextOverflowError("overflow"), req, invoke))
await _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)
+10 -15
View File
@@ -16,7 +16,6 @@ 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
@@ -152,7 +151,7 @@ class TestStartAsyncTaskInjection:
"configurable": {"model": "gpt-5", "model_provider": "openai"}
}
def test_async_start_injects_config(self, restore_model_passthrough_patch):
async def test_async_start_injects_config(self, restore_model_passthrough_patch):
try:
from deepagents.middleware import async_subagents as ds_mod
except ImportError:
@@ -176,12 +175,10 @@ class TestStartAsyncTaskInjection:
"EvoScientist.EvoScientist._ensure_config",
return_value=_stub_cfg(model="claude-haiku-4-5", provider="anthropic"),
):
_run(
tool.coroutine(
description="hi",
subagent_type="writing-agent",
runtime=_runtime_stub(),
)
await tool.coroutine(
description="hi",
subagent_type="writing-agent",
runtime=_runtime_stub(),
)
runs_async.create.assert_awaited_once()
@@ -267,7 +264,7 @@ class TestUpdateAsyncTaskInjection:
"last_updated_at": "2026-05-07T00:00:00Z",
}
def test_async_update_injects_config(self, restore_model_passthrough_patch):
async 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
@@ -296,12 +293,10 @@ class TestUpdateAsyncTaskInjection:
"EvoScientist.EvoScientist._ensure_config",
return_value=_stub_cfg(model="gpt-5", provider="openai"),
):
_run(
tool.coroutine(
task_id="thread-001",
message="follow up async",
runtime=runtime,
)
await tool.coroutine(
task_id="thread-001",
message="follow up async",
runtime=runtime,
)
runs_async.create.assert_awaited_once()
+4 -6
View File
@@ -2,11 +2,9 @@
from unittest.mock import AsyncMock, MagicMock
from tests.conftest import run_async as _run
class TestNewCommand:
def test_execute_calls_start_new_session(self):
async def test_execute_calls_start_new_session(self):
from EvoScientist.commands.base import CommandContext
from EvoScientist.commands.implementation.session import NewCommand
@@ -18,7 +16,7 @@ class TestNewCommand:
ui=ui,
workspace_dir="/old/ws",
)
_run(NewCommand().execute(ctx, []))
await NewCommand().execute(ctx, [])
ui.start_new_session.assert_awaited_once()
def test_requires_agent_false(self):
@@ -26,7 +24,7 @@ class TestNewCommand:
assert NewCommand().requires_agent is False
def test_no_agent_access(self):
async 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
@@ -35,4 +33,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
_run(NewCommand().execute(ctx, []))
await NewCommand().execute(ctx, [])
+49 -64
View File
@@ -1655,9 +1655,7 @@ def test_turn_compaction_uses_latest_user_turn_only():
]
def test_lifecycle_schedules_turn_worker_without_awaiting(
tmp_path, monkeypatch, run_async
):
async def test_lifecycle_schedules_turn_worker_without_awaiting(tmp_path, monkeypatch):
memory_dir = tmp_path / "memories"
workspace_dir = tmp_path / "workspace"
calls = []
@@ -1682,21 +1680,18 @@ def test_lifecycle_schedules_turn_worker_without_awaiting(
)
runtime = _runtime("thread-1")
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())
state: AgentState[object] = {
"messages": [
HumanMessage("previous turn"),
AIMessage("previous answer"),
HumanMessage("hi"),
AIMessage("done"),
]
}
await middleware.aafter_agent(
state,
runtime,
)
assert len(calls) == 1
request, hooks = calls[0]
@@ -2182,10 +2177,9 @@ def test_observation_linker_does_not_launch_when_observations_disabled(
launch_call.assert_not_called()
def test_async_observation_linker_does_not_launch_when_observations_disabled(
async def test_async_observation_linker_does_not_launch_when_observations_disabled(
tmp_path,
monkeypatch,
run_async,
):
context = _linker_context(
memory_dir=tmp_path / "memories",
@@ -2200,7 +2194,7 @@ def test_async_observation_linker_does_not_launch_when_observations_disabled(
launch_call = MagicMock()
monkeypatch.setattr(memory_launch, "alaunch_background_run", launch_call)
run = run_async(memory_launch.alaunch_observation_linker(context))
run = await memory_launch.alaunch_observation_linker(context)
assert run is None
launch_call.assert_not_called()
@@ -2335,8 +2329,8 @@ def test_sync_memory_worker_watcher_untracks_without_counting_on_poll_abort(
assert status.observations_recorded == 0
def test_async_memory_worker_watcher_untracks_without_counting_on_poll_abort(
tmp_path, monkeypatch, run_async
async def test_async_memory_worker_watcher_untracks_without_counting_on_poll_abort(
tmp_path, monkeypatch
):
memory_dir = tmp_path / "memories"
_mark_worker_started(memory_dir)
@@ -2348,14 +2342,12 @@ def test_async_memory_worker_watcher_untracks_without_counting_on_poll_abort(
async def get(self, **_kwargs):
raise RuntimeError("poll failed")
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),
)
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),
)
status = worker_activity.memory_worker_status()
assert status.is_running is False
@@ -2363,8 +2355,8 @@ def test_async_memory_worker_watcher_untracks_without_counting_on_poll_abort(
assert status.observations_recorded == 0
def test_async_memory_worker_watcher_counts_completion_under_blockbuster(
tmp_path, run_async
async def test_async_memory_worker_watcher_counts_completion_under_blockbuster(
tmp_path,
):
memory_dir = tmp_path / "memories"
_mark_worker_started(memory_dir)
@@ -2376,20 +2368,17 @@ def test_async_memory_worker_watcher_counts_completion_under_blockbuster(
async def get(self, **_kwargs):
return {"status": "success"}
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())
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()
status = worker_activity.memory_worker_status()
assert status.is_running is False
assert status.profile_updates == 1
@@ -2527,7 +2516,7 @@ def test_memory_worker_marks_active_status(tmp_path, monkeypatch):
assert status.observations_recorded == 1
def test_async_memory_worker_offloads_blocking_work(tmp_path, monkeypatch, run_async):
async def test_async_memory_worker_offloads_blocking_work(tmp_path, monkeypatch):
monkeypatch.setattr(
background_runs, "default_background_run_url", lambda: "http://x"
)
@@ -2569,22 +2558,18 @@ def test_async_memory_worker_offloads_blocking_work(tmp_path, monkeypatch, run_a
spawned: list[background_runs.BackgroundRun] = []
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())
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,
)
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
+20 -21
View File
@@ -17,7 +17,6 @@ from EvoScientist.llm.ollama_discovery import (
discover_ollama_models,
validate_ollama_connection,
)
from tests.conftest import run_async as _run
class TestValidateOllamaConnection:
@@ -71,17 +70,17 @@ class TestValidateOllamaConnection:
class TestDiscoverOllamaModels:
"""Async probe — contract: never raise, return list[str]."""
def test_empty_base_url_returns_empty_without_http(self):
async 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 = _run(discover_ollama_models(""))
names = await discover_ollama_models("")
assert names == []
def test_none_base_url_returns_empty(self):
names = _run(discover_ollama_models(None))
async def test_none_base_url_returns_empty(self):
names = await discover_ollama_models(None)
assert names == []
def test_200_returns_names(self):
async def test_200_returns_names(self):
async def fake_get(self, url):
resp = MagicMock()
resp.status_code = 200
@@ -93,10 +92,10 @@ class TestDiscoverOllamaModels:
return resp
with patch.object(httpx.AsyncClient, "get", fake_get):
names = _run(discover_ollama_models("http://localhost:11434"))
names = await discover_ollama_models("http://localhost:11434")
assert names == ["llama3.3:latest", "qwen3:8b"]
def test_strips_entries_without_name(self):
async def test_strips_entries_without_name(self):
async def fake_get(self, url):
resp = MagicMock()
resp.status_code = 200
@@ -112,36 +111,36 @@ class TestDiscoverOllamaModels:
return resp
with patch.object(httpx.AsyncClient, "get", fake_get):
names = _run(discover_ollama_models("http://localhost:11434"))
names = await discover_ollama_models("http://localhost:11434")
assert names == ["llama3.3"]
def test_timeout_returns_empty(self):
async 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 = _run(discover_ollama_models("http://localhost:11434"))
names = await discover_ollama_models("http://localhost:11434")
assert names == []
def test_connect_error_returns_empty(self):
async 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 = _run(discover_ollama_models("http://localhost:11434"))
names = await discover_ollama_models("http://localhost:11434")
assert names == []
def test_non_200_returns_empty(self):
async 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 = _run(discover_ollama_models("http://localhost:11434"))
names = await discover_ollama_models("http://localhost:11434")
assert names == []
def test_malformed_json_returns_empty(self):
async def test_malformed_json_returns_empty(self):
async def fake_get(self, url):
resp = MagicMock()
resp.status_code = 200
@@ -149,10 +148,10 @@ class TestDiscoverOllamaModels:
return resp
with patch.object(httpx.AsyncClient, "get", fake_get):
names = _run(discover_ollama_models("http://localhost:11434"))
names = await discover_ollama_models("http://localhost:11434")
assert names == []
def test_missing_models_key_returns_empty(self):
async def test_missing_models_key_returns_empty(self):
async def fake_get(self, url):
resp = MagicMock()
resp.status_code = 200
@@ -160,10 +159,10 @@ class TestDiscoverOllamaModels:
return resp
with patch.object(httpx.AsyncClient, "get", fake_get):
names = _run(discover_ollama_models("http://localhost:11434"))
names = await discover_ollama_models("http://localhost:11434")
assert names == []
def test_trailing_slash_stripped_from_url(self):
async def test_trailing_slash_stripped_from_url(self):
called = {}
async def fake_get(self, url):
@@ -174,7 +173,7 @@ class TestDiscoverOllamaModels:
return resp
with patch.object(httpx.AsyncClient, "get", fake_get):
_run(discover_ollama_models("http://localhost:11434/"))
await discover_ollama_models("http://localhost:11434/")
assert called["url"] == "http://localhost:11434/api/tags"
+309
View File
@@ -184,6 +184,38 @@ 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
# =============================================================================
@@ -386,6 +418,109 @@ 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."""
@@ -1266,6 +1401,7 @@ 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
@@ -1289,12 +1425,185 @@ 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)."""
+4 -6
View File
@@ -79,14 +79,13 @@ class TestPickSkillsInteractive:
class TestInstallSkillsHandlesEmpty:
"""InstallSkills.execute must distinguish None vs [] from the picker."""
def test_empty_list_suppresses_cancel_message(self):
async 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
@@ -97,18 +96,17 @@ class TestInstallSkillsHandlesEmpty:
"EvoScientist.tools.skills_manager.fetch_remote_skill_index",
return_value=_INDEX,
):
_run(InstallSkills().execute(ctx, []))
await 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)
def test_none_shows_cancel_message(self):
async 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
@@ -119,7 +117,7 @@ class TestInstallSkillsHandlesEmpty:
"EvoScientist.tools.skills_manager.fetch_remote_skill_index",
return_value=_INDEX,
):
_run(InstallSkills().execute(ctx, []))
await 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)
+14 -20
View File
@@ -344,9 +344,7 @@ def test_profile_memory_uses_path_pointers_when_profiles_exceed_budget(
)
def test_profile_memory_async_path_bootstraps_and_injects(
tmp_path, monkeypatch, run_async
):
async def test_profile_memory_async_path_bootstraps_and_injects(tmp_path, monkeypatch):
memories = tmp_path / "memories"
workspace = tmp_path / "workspace"
workspace.mkdir()
@@ -356,7 +354,7 @@ def test_profile_memory_async_path_bootstraps_and_injects(
return request
middleware = memory_module.create_memory_middleware(str(memories))
run_async(middleware.awrap_model_call(_request(), _handler))
await middleware.awrap_model_call(_request(), _handler)
assert (memories / "profile" / "USER_PROFILE.md").exists()
@@ -399,8 +397,8 @@ def test_profile_memory_read_failure_uses_path_pointers_without_overwriting(
assert soul_path.read_bytes() == original_bytes
def test_profile_memory_async_path_inlines_content_under_blockbuster(
tmp_path, monkeypatch, run_async
async def test_profile_memory_async_path_inlines_content_under_blockbuster(
tmp_path, monkeypatch
):
memories = tmp_path / "memories"
workspace = tmp_path / "workspace"
@@ -425,17 +423,13 @@ def test_profile_memory_async_path_inlines_content_under_blockbuster(
monkeypatch.setattr(middleware, "_read_profile_memory", tracked_read_profile_memory)
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())
event_loop_thread = threading.get_ident()
blocker = BlockBuster(scanned_modules=memory_module)
blocker.activate()
try:
modified = await middleware.amodify_request(_request())
finally:
blocker.deactivate()
assert call_threads
assert all(thread_id != event_loop_thread for thread_id in call_threads)
@@ -534,8 +528,8 @@ def test_profile_memory_uses_explicit_workspace_for_project_profile(
).exists()
def test_profile_memory_resolves_project_id_once_per_middleware(
tmp_path, monkeypatch, run_async
async def test_profile_memory_resolves_project_id_once_per_middleware(
tmp_path, monkeypatch
):
memories = tmp_path / "memories"
workspace = tmp_path / "workspace"
@@ -552,7 +546,7 @@ 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())
run_async(middleware.amodify_request(_request()))
await middleware.amodify_request(_request())
assert calls == [workspace]
assert middleware.project_id == "P-cached-project"
+35 -38
View File
@@ -8,7 +8,6 @@ from EvoScientist.channels.qq.channel import (
QQConfig,
_build_qq_keyboard,
)
from tests.conftest import run_async as _run
class TestQQChannelSend:
@@ -22,7 +21,7 @@ class TestQQChannelSend:
channel._client.api.post_group_message = AsyncMock()
return channel
def test_send_prefers_native_markdown_for_c2c(self):
async def test_send_prefers_native_markdown_for_c2c(self):
channel = self._make_ready_channel()
msg = OutboundMessage(
channel="qq",
@@ -35,7 +34,7 @@ class TestQQChannelSend:
},
)
assert _run(channel.send(msg)) is True
assert await channel.send(msg) is True
channel._client.api.post_c2c_message.assert_awaited_once()
sent = channel._client.api.post_c2c_message.await_args.kwargs
@@ -46,7 +45,7 @@ class TestQQChannelSend:
assert sent["msg_seq"] == 1
assert "content" not in sent
def test_send_falls_back_to_plain_text_when_markdown_send_fails(self):
async 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(
@@ -63,7 +62,7 @@ class TestQQChannelSend:
},
)
assert _run(channel.send(msg)) is True
assert await 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
@@ -80,7 +79,7 @@ class TestQQChannelSend:
# trigger "duplicate msg_seq".
assert second["msg_seq"] == 2
def test_send_does_not_fallback_on_transport_error(self):
async def test_send_does_not_fallback_on_transport_error(self):
channel = self._make_ready_channel()
async def _send_once(coro_factory, max_retries=3):
@@ -101,13 +100,13 @@ class TestQQChannelSend:
},
)
assert _run(channel.send(msg)) is False
assert await 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
def test_send_does_not_fallback_when_transport_error_mentions_markdown(self):
async 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."""
@@ -133,10 +132,10 @@ class TestQQChannelSend:
},
)
assert _run(channel.send(msg)) is False
assert await channel.send(msg) is False
channel._client.api.post_c2c_message.assert_awaited_once()
def test_send_falls_back_on_qq_server_error_code(self):
async 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()
@@ -159,7 +158,7 @@ class TestQQChannelSend:
},
)
assert _run(channel.send(msg)) is True
assert await 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
@@ -231,7 +230,7 @@ class TestQQSendWithButtons:
channel._client.api.post_group_message = AsyncMock()
return channel
def test_c2c_send_attaches_keyboard(self):
async def test_c2c_send_attaches_keyboard(self):
channel = self._make_channel()
msg = OutboundMessage(
channel="qq",
@@ -247,7 +246,7 @@ class TestQQSendWithButtons:
],
},
)
assert _run(channel.send(msg)) is True
assert await channel.send(msg) is True
sent = channel._client.api.post_c2c_message.await_args.kwargs
assert sent["msg_type"] == 2
@@ -256,7 +255,7 @@ class TestQQSendWithButtons:
assert rows[0]["buttons"][0]["action"]["data"] == "1"
assert rows[1]["buttons"][0]["action"]["data"] == "2"
def test_group_send_does_not_attach_keyboard(self):
async def test_group_send_does_not_attach_keyboard(self):
"""Group keyboards are out of scope — silently dropped."""
channel = self._make_channel()
msg = OutboundMessage(
@@ -270,11 +269,11 @@ class TestQQSendWithButtons:
"buttons": [{"text": "Approve", "value": "1"}],
},
)
assert _run(channel.send(msg)) is True
assert await channel.send(msg) is True
sent = channel._client.api.post_group_message.await_args.kwargs
assert "keyboard" not in sent
def test_fallback_appends_button_hint_when_keyboard_present(self):
async 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`).
@@ -302,7 +301,7 @@ class TestQQSendWithButtons:
],
},
)
assert _run(channel.send(msg)) is True
assert await channel.send(msg) is True
plain_call = channel._client.api.post_c2c_message.await_args_list[1].kwargs
assert plain_call["msg_type"] == 0
@@ -311,7 +310,7 @@ class TestQQSendWithButtons:
assert "1=Approve" in plain_call["content"]
assert "2=Reject" in plain_call["content"]
def test_fallback_hint_handles_non_string_button_value(self):
async 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()
@@ -335,7 +334,7 @@ class TestQQSendWithButtons:
],
},
)
assert _run(channel.send(msg)) is True
assert await 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"]
@@ -392,9 +391,9 @@ class TestQQInteractionCallback:
)
return interaction
def test_click_publishes_to_bus_with_button_data(self):
async def test_click_publishes_to_bus_with_button_data(self):
channel = self._make_channel()
_run(channel._on_interaction(self._make_interaction("1")))
await channel._on_interaction(self._make_interaction("1"))
channel._bus.publish_inbound.assert_awaited_once()
inbound = channel._bus.publish_inbound.await_args[0][0]
@@ -405,63 +404,61 @@ class TestQQInteractionCallback:
assert inbound.metadata["button_value"] == "1"
assert inbound.metadata["msg_type"] == "c2c"
def test_click_acks_interaction(self):
async def test_click_acks_interaction(self):
channel = self._make_channel()
_run(channel._on_interaction(self._make_interaction("1")))
await channel._on_interaction(self._make_interaction("1"))
channel._client.api.on_interaction_result.assert_awaited_once_with("intr_1", 0)
def test_click_bypasses_debounce(self):
async def test_click_bypasses_debounce(self):
"""Click never hits queue_message (debounce buffer)."""
channel = self._make_channel()
channel.queue_message = AsyncMock()
_run(channel._on_interaction(self._make_interaction("3")))
await channel._on_interaction(self._make_interaction("3"))
channel.queue_message.assert_not_called()
channel._bus.publish_inbound.assert_awaited_once()
def test_group_interaction_ignored(self):
async 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"
_run(channel._on_interaction(intr))
await 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)
def test_click_dropped_when_middleware_rejects(self):
async def test_click_dropped_when_middleware_rejects(self):
channel = self._make_channel()
channel._build_inbound_async = AsyncMock(return_value=None)
_run(channel._on_interaction(self._make_interaction("1")))
await 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()
def test_empty_button_data_falls_back_to_button_id(self):
async def test_empty_button_data_falls_back_to_button_id(self):
channel = self._make_channel()
_run(
channel._on_interaction(
self._make_interaction(button_data="", button_id="btn_3")
)
await 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"
def test_ack_fires_even_when_handler_throws(self):
async 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.
_run(channel._on_interaction(self._make_interaction("1")))
await channel._on_interaction(self._make_interaction("1"))
channel._client.api.on_interaction_result.assert_awaited_once_with("intr_1", 0)
def test_button_value_metadata_is_string_coerced(self):
async 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)
_run(channel._on_interaction(intr))
await channel._on_interaction(intr)
inbound = channel._bus.publish_inbound.await_args[0][0]
assert inbound.content == "42"
assert inbound.metadata["button_value"] == "42"
+16 -17
View File
@@ -2,7 +2,6 @@
from unittest.mock import AsyncMock, MagicMock
from tests.conftest import run_async as _run
from tests.fakes import FakeGraphGateway, FakeThreadStore
@@ -24,7 +23,7 @@ def _ctx(thread_id="current", workspace_dir="/ws", thread_store=None):
class TestResumeCommand:
def test_with_arg_resolves_and_calls_ui(self):
async def test_with_arg_resolves_and_calls_ui(self):
from EvoScientist.commands.implementation.session import ResumeCommand
ctx, ui = _ctx(
@@ -33,23 +32,23 @@ class TestResumeCommand:
metadata={"workspace_dir": "/restored"},
)
)
_run(ResumeCommand().execute(ctx, ["target-tid"]))
await 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"
def test_no_arg_empty_threads_prints_message(self):
async def test_no_arg_empty_threads_prints_message(self):
from EvoScientist.commands.implementation.session import ResumeCommand
ctx, ui = _ctx()
_run(ResumeCommand().execute(ctx, []))
await 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()
def test_no_arg_calls_picker(self):
async def test_no_arg_calls_picker(self):
from EvoScientist.commands.implementation.session import ResumeCommand
ctx, ui = _ctx()
@@ -60,11 +59,11 @@ class TestResumeCommand:
resolved_thread_id="picked-tid",
)
ctx.graph_gateway = FakeGraphGateway(thread_store=store)
_run(ResumeCommand().execute(ctx, []))
await ResumeCommand().execute(ctx, [])
ui.wait_for_thread_pick.assert_awaited_once()
ui.handle_session_resume.assert_awaited_once()
def test_picker_cancel_returns(self):
async def test_picker_cancel_returns(self):
from EvoScientist.commands.implementation.session import ResumeCommand
ctx, ui = _ctx()
@@ -72,28 +71,28 @@ class TestResumeCommand:
threads = [{"thread_id": "t1", "preview": "", "message_count": 0}]
store = FakeThreadStore(threads=threads)
ctx.graph_gateway = FakeGraphGateway(thread_store=store)
_run(ResumeCommand().execute(ctx, []))
await ResumeCommand().execute(ctx, [])
ui.handle_session_resume.assert_not_called()
def test_ambiguous_prefix(self):
async def test_ambiguous_prefix(self):
from EvoScientist.commands.implementation.session import ResumeCommand
ctx, ui = _ctx(thread_store=FakeThreadStore(matches=["abc-one", "abc-two"]))
_run(ResumeCommand().execute(ctx, ["abc"]))
await 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()
def test_not_found(self):
async def test_not_found(self):
from EvoScientist.commands.implementation.session import ResumeCommand
ctx, ui = _ctx()
_run(ResumeCommand().execute(ctx, ["missing"]))
await 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()
def test_prefix_resolves_to_unique_match(self):
async def test_prefix_resolves_to_unique_match(self):
from EvoScientist.commands.implementation.session import ResumeCommand
ctx, ui = _ctx(
@@ -102,18 +101,18 @@ class TestResumeCommand:
metadata={"workspace_dir": "/ws1"},
)
)
_run(ResumeCommand().execute(ctx, ["abc"]))
await ResumeCommand().execute(ctx, ["abc"])
ui.handle_session_resume.assert_awaited_once_with("abc-one", "/ws1")
assert ctx.thread_id == "abc-one"
def test_empty_workspace_metadata_preserves_ctx_workspace(self):
async 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={}),
)
_run(ResumeCommand().execute(ctx, ["tid"]))
await 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)
+46 -56
View File
@@ -5,8 +5,6 @@ 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."""
@@ -40,9 +38,9 @@ class TestBasicIO:
ui.mount_renderable(table)
console.print.assert_called_once_with(table)
def test_flush_is_async_noop(self):
async def test_flush_is_async_noop(self):
ui, console = _make_ui()
_run(ui.flush())
await ui.flush()
# flush should not print anything
console.print.assert_not_called()
@@ -50,33 +48,29 @@ class TestBasicIO:
class TestWaitForModelPick:
"""CLI model picker fallback: print table + return None."""
def test_returns_none(self):
async def test_returns_none(self):
ui, _ = _make_ui()
entries = [
("claude-sonnet-4-6", "anthropic/claude-sonnet", "anthropic"),
("gpt-4o", "openai/gpt-4o", "openai"),
]
result = _run(
ui.wait_for_model_pick(
entries,
current_model="claude-sonnet-4-6",
current_provider="anthropic",
)
result = await ui.wait_for_model_pick(
entries,
current_model="claude-sonnet-4-6",
current_provider="anthropic",
)
assert result is None
def test_prints_table_with_current_model_marker(self):
async 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"),
]
_run(
ui.wait_for_model_pick(
entries,
current_model="claude-sonnet-4-6",
current_provider="anthropic",
)
await 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
@@ -87,27 +81,23 @@ class TestWaitForModelPick:
assert "Usage: /model" in usage_arg
assert "--save" in usage_arg
def test_no_current_model_no_marker(self):
async def test_no_current_model_no_marker(self):
ui, console = _make_ui()
entries = [("claude-sonnet-4-6", "anthropic/claude-sonnet", "anthropic")]
_run(
ui.wait_for_model_pick(
entries,
current_model=None,
current_provider=None,
)
await 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
def test_empty_entries_still_prints_header_and_usage(self):
async def test_empty_entries_still_prints_header_and_usage(self):
ui, console = _make_ui()
result = _run(
ui.wait_for_model_pick(
[],
current_model=None,
current_provider=None,
)
result = await 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
@@ -213,7 +203,7 @@ class TestWaitForThreadPick:
},
]
def test_returns_selected_thread_id(self, monkeypatch):
async def test_returns_selected_thread_id(self, monkeypatch):
import EvoScientist.cli.rich_command_ui as mod
ui, _ = _make_ui()
@@ -226,7 +216,7 @@ class TestWaitForThreadPick:
return prompt
monkeypatch.setattr("questionary.select", fake_select)
result = _run(ui.wait_for_thread_pick(self._threads(), "abc123", "pick:"))
result = await 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 +
@@ -235,14 +225,14 @@ class TestWaitForThreadPick:
# Table import removed; this test no longer depends on console output.
assert mod.RichCLICommandUI is not None # sanity
def test_cancel_returns_none(self, monkeypatch):
async def test_cancel_returns_none(self, monkeypatch):
ui, _ = _make_ui()
prompt = self._fake_prompt(None)
monkeypatch.setattr("questionary.select", lambda *a, **k: prompt)
result = _run(ui.wait_for_thread_pick(self._threads(), "abc123", "pick:"))
result = await ui.wait_for_thread_pick(self._threads(), "abc123", "pick:")
assert result is None
def test_current_thread_marker_in_label(self, monkeypatch):
async def test_current_thread_marker_in_label(self, monkeypatch):
ui, _ = _make_ui()
prompt = self._fake_prompt(None)
captured_choices: list = []
@@ -252,7 +242,7 @@ class TestWaitForThreadPick:
return prompt
monkeypatch.setattr("questionary.select", fake_select)
_run(ui.wait_for_thread_pick(self._threads(), "abc123", "pick:"))
await 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)
@@ -282,38 +272,38 @@ class TestCompactIndicator:
class TestPhaseBMigrated:
"""Session lifecycle callbacks (start/resume) filled in Phase B."""
def test_start_new_session_fires_callback(self):
async def test_start_new_session_fires_callback(self):
from unittest.mock import AsyncMock
cb = AsyncMock()
ui, _ = _make_ui(on_start_new_session=cb)
_run(ui.start_new_session())
await ui.start_new_session()
cb.assert_awaited_once()
def test_start_new_session_without_callback_is_noop(self):
async def test_start_new_session_without_callback_is_noop(self):
ui, console = _make_ui()
_run(ui.start_new_session())
await ui.start_new_session()
console.print.assert_not_called()
def test_handle_session_resume_awaits_callback(self):
async def test_handle_session_resume_awaits_callback(self):
from unittest.mock import AsyncMock
cb = AsyncMock()
ui, _ = _make_ui(on_handle_session_resume=cb)
_run(ui.handle_session_resume("tid-x", "/workspace"))
await ui.handle_session_resume("tid-x", "/workspace")
cb.assert_awaited_once_with("tid-x", "/workspace")
def test_handle_session_resume_without_callback_is_noop(self):
async def test_handle_session_resume_without_callback_is_noop(self):
ui, _ = _make_ui()
# Should not raise
_run(ui.handle_session_resume("tid-x"))
await ui.handle_session_resume("tid-x")
def test_handle_session_resume_workspace_defaults_none(self):
async def test_handle_session_resume_workspace_defaults_none(self):
from unittest.mock import AsyncMock
cb = AsyncMock()
ui, _ = _make_ui(on_handle_session_resume=cb)
_run(ui.handle_session_resume("tid-x"))
await ui.handle_session_resume("tid-x")
cb.assert_awaited_once_with("tid-x", None)
@@ -321,7 +311,7 @@ class TestPhaseCMigrated:
"""Skill/MCP browse pickers delegate to questionary helpers via
``asyncio.to_thread`` since questionary blocks the event loop."""
def test_skill_browse_delegates_to_picker(self, monkeypatch):
async def test_skill_browse_delegates_to_picker(self, monkeypatch):
from unittest.mock import MagicMock
picker = MagicMock(return_value=["skill-a", "skill-b"])
@@ -330,11 +320,11 @@ class TestPhaseCMigrated:
picker,
)
ui, _ = _make_ui()
result = _run(ui.wait_for_skill_browse([{"name": "a"}], {"installed"}, "core"))
result = await ui.wait_for_skill_browse([{"name": "a"}], {"installed"}, "core")
assert result == ["skill-a", "skill-b"]
picker.assert_called_once_with([{"name": "a"}], {"installed"}, "core")
def test_skill_browse_cancel_returns_none(self, monkeypatch):
async def test_skill_browse_cancel_returns_none(self, monkeypatch):
from unittest.mock import MagicMock
monkeypatch.setattr(
@@ -342,10 +332,10 @@ class TestPhaseCMigrated:
MagicMock(return_value=None),
)
ui, _ = _make_ui()
result = _run(ui.wait_for_skill_browse([], set(), ""))
result = await ui.wait_for_skill_browse([], set(), "")
assert result is None
def test_mcp_browse_delegates_to_picker(self, monkeypatch):
async def test_mcp_browse_delegates_to_picker(self, monkeypatch):
from unittest.mock import MagicMock
sentinel_entries = [MagicMock(name="entry1"), MagicMock(name="entry2")]
@@ -355,11 +345,11 @@ class TestPhaseCMigrated:
picker,
)
ui, _ = _make_ui()
result = _run(ui.wait_for_mcp_browse([MagicMock()], {"configured"}, ""))
result = await ui.wait_for_mcp_browse([MagicMock()], {"configured"}, "")
assert result is sentinel_entries
picker.assert_called_once()
def test_mcp_browse_cancel_returns_none(self, monkeypatch):
async def test_mcp_browse_cancel_returns_none(self, monkeypatch):
from unittest.mock import MagicMock
monkeypatch.setattr(
@@ -367,5 +357,5 @@ class TestPhaseCMigrated:
MagicMock(return_value=None),
)
ui, _ = _make_ui()
result = _run(ui.wait_for_mcp_browse([], set(), ""))
result = await ui.wait_for_mcp_browse([], set(), "")
assert result is None
+24 -28
View File
@@ -2,8 +2,6 @@
from unittest.mock import MagicMock, patch
from tests.conftest import run_async as _run
def _ctx():
from EvoScientist.commands.base import CommandContext
@@ -12,17 +10,17 @@ def _ctx():
return CommandContext(agent=None, thread_id="tid", ui=ui), ui
def test_list_when_backend_down():
async 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):
_run(ScheduleCommand().execute(ctx, ["list"]))
await 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)
def test_add_parses_five_field_cron_and_prompt():
async def test_add_parses_five_field_cron_and_prompt():
from EvoScientist.commands.implementation.schedule import ScheduleCommand
ctx, _ui = _ctx()
@@ -33,17 +31,15 @@ def test_add_parses_five_field_cron_and_prompt():
return_value={"cron_id": "c-9"},
) as mk,
):
_run(
ScheduleCommand().execute(
ctx, ["add", "*/10", "*", "*", "*", "*", "search", "uk", "weather"]
)
await ScheduleCommand().execute(
ctx, ["add", "*/10", "*", "*", "*", "*", "search", "uk", "weather"]
)
kw = mk.call_args.kwargs
assert kw["schedule"] == "*/10 * * * *"
assert kw["prompt"] == "search uk weather"
def test_list_renders_table():
async def test_list_renders_table():
from EvoScientist.commands.implementation.schedule import ScheduleCommand
ctx, ui = _ctx()
@@ -60,11 +56,11 @@ def test_list_renders_table():
patch("EvoScientist.cron.schedule.is_available", return_value=True),
patch("EvoScientist.cron.schedule.list_schedules", return_value=rows),
):
_run(ScheduleCommand().execute(ctx, ["list"]))
await ScheduleCommand().execute(ctx, ["list"])
ui.mount_renderable.assert_called_once()
def test_add_parses_quoted_cron_and_prompt():
async def test_add_parses_quoted_cron_and_prompt():
from EvoScientist.commands.implementation.schedule import ScheduleCommand
ctx, _ui = _ctx()
@@ -75,15 +71,15 @@ def test_add_parses_quoted_cron_and_prompt():
return_value={"cron_id": "c-9"},
) as mk,
):
_run(
ScheduleCommand().execute(ctx, ["add", "*/10 * * * *", "search uk weather"])
await ScheduleCommand().execute(
ctx, ["add", "*/10 * * * *", "search uk weather"]
)
kw = mk.call_args.kwargs
assert kw["schedule"] == "*/10 * * * *"
assert kw["prompt"] == "search uk weather"
def test_run_with_matching_prefix_fires_matched_prompt():
async def test_run_with_matching_prefix_fires_matched_prompt():
from EvoScientist.commands.implementation.schedule import ScheduleCommand
ctx, _ui = _ctx()
@@ -96,11 +92,11 @@ def test_run_with_matching_prefix_fires_matched_prompt():
return_value={"run_id": "r-1"},
) as rn,
):
_run(ScheduleCommand().execute(ctx, ["run", "c-123"]))
await ScheduleCommand().execute(ctx, ["run", "c-123"])
rn.assert_called_once_with("do the thing")
def test_run_with_no_match_reports():
async def test_run_with_no_match_reports():
from EvoScientist.commands.implementation.schedule import ScheduleCommand
ctx, ui = _ctx()
@@ -109,13 +105,13 @@ def test_run_with_no_match_reports():
patch("EvoScientist.cron.schedule.list_schedules", return_value=[]),
patch("EvoScientist.cron.schedule.run_now") as rn,
):
_run(ScheduleCommand().execute(ctx, ["run", "nope"]))
await 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)
def test_pause_resume_set_enabled_with_resolved_id():
async def test_pause_resume_set_enabled_with_resolved_id():
from EvoScientist.commands.implementation.schedule import ScheduleCommand
rows = [{"cron_id": "c-abcdef", "metadata": {"name": "t"}}]
@@ -126,7 +122,7 @@ 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,
):
_run(ScheduleCommand().execute(ctx, [sub, "c-abc"]))
await ScheduleCommand().execute(ctx, [sub, "c-abc"])
se.assert_called_once_with("c-abcdef", expected)
@@ -135,7 +131,7 @@ def test_pause_resume_set_enabled_with_resolved_id():
# ---------------------------------------------------------------------------
def test_list_error_shows_red_message_no_exception():
async 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
@@ -147,7 +143,7 @@ def test_list_error_shows_red_message_no_exception():
side_effect=RuntimeError("backend gone"),
),
):
_run(ScheduleCommand().execute(ctx, ["list"]))
await 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)
@@ -158,7 +154,7 @@ def test_list_error_shows_red_message_no_exception():
# ---------------------------------------------------------------------------
def test_remove_ambiguous_prefix_aborts_without_deleting():
async 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
@@ -172,7 +168,7 @@ 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,
):
_run(ScheduleCommand().execute(ctx, ["remove", "abc"]))
await 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)
@@ -183,7 +179,7 @@ def test_remove_ambiguous_prefix_aborts_without_deleting():
# ---------------------------------------------------------------------------
def test_remove_backend_error_shows_red_error_not_no_match():
async 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
@@ -196,7 +192,7 @@ def test_remove_backend_error_shows_red_error_not_no_match():
),
patch("EvoScientist.cron.schedule.delete_schedule") as mk,
):
_run(ScheduleCommand().execute(ctx, ["remove", "abc"]))
await 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}"
@@ -209,7 +205,7 @@ def test_remove_backend_error_shows_red_error_not_no_match():
)
def test_add_name_sanitized_from_nasty_prompt():
async def test_add_name_sanitized_from_nasty_prompt():
"""B3: prompt with newline / slashes / special chars → clean kebab-case name."""
import re
@@ -225,7 +221,7 @@ def test_add_name_sanitized_from_nasty_prompt():
return_value={"cron_id": "c-x"},
) as mk,
):
_run(ScheduleCommand().execute(ctx, ["add", "*/5 * * * *", nasty_prompt]))
await 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
+35 -36
View File
@@ -27,7 +27,6 @@ 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
@@ -71,7 +70,7 @@ def _runtime_state(
)
def test_hook_updates_runtime_state_on_agent_swap():
async 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."""
@@ -86,12 +85,12 @@ def test_hook_updates_runtime_state_on_agent_swap():
cmd = MagicMock()
cmd.name = "/model"
_run(hook(ctx, original_agent, cmd))
await hook(ctx, original_agent, cmd)
assert state.agent is new_agent
def test_hook_syncs_channel_runtime():
async 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")
@@ -109,13 +108,13 @@ def test_hook_syncs_channel_runtime():
cmd = MagicMock()
cmd.name = "/model"
_run(hook(ctx, original_agent, cmd))
await hook(ctx, original_agent, cmd)
assert runtime.agent is new_agent
assert runtime.thread_id == "t"
def test_hook_noop_when_agent_unchanged():
async 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")
@@ -128,12 +127,12 @@ def test_hook_noop_when_agent_unchanged():
cmd = MagicMock()
cmd.name = "/evoskills"
_run(hook(ctx, original_agent, cmd))
await hook(ctx, original_agent, cmd)
assert state.agent is original_agent
def test_hook_noop_when_ctx_agent_is_none():
async 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")
@@ -146,12 +145,12 @@ def test_hook_noop_when_ctx_agent_is_none():
cmd = MagicMock()
cmd.name = "/whatever"
_run(hook(ctx, original_agent, cmd))
await hook(ctx, original_agent, cmd)
assert state.agent is original_agent
def test_hook_updates_thread_id_on_resume():
async 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."""
@@ -166,12 +165,12 @@ def test_hook_updates_thread_id_on_resume():
cmd = MagicMock()
cmd.name = "/resume"
_run(hook(ctx, agent, cmd))
await hook(ctx, agent, cmd)
assert state.thread_id == "new-tid"
def test_hook_updates_workspace_dir_on_resume():
async 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")
@@ -201,7 +200,7 @@ def test_hook_updates_workspace_dir_on_resume():
return_value=reloaded_agent,
) as load_agent,
):
_run(hook(ctx, old_agent, cmd))
await 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)
@@ -209,7 +208,7 @@ def test_hook_updates_workspace_dir_on_resume():
assert state.agent is reloaded_agent
def test_hook_syncs_channel_runtime_thread_id():
async 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")
@@ -224,12 +223,12 @@ def test_hook_syncs_channel_runtime_thread_id():
cmd = MagicMock()
cmd.name = "/resume"
_run(hook(ctx, agent, cmd))
await hook(ctx, agent, cmd)
assert runtime.thread_id == "new-tid"
def test_hook_noop_when_thread_id_unchanged():
async 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")
@@ -241,12 +240,12 @@ def test_hook_noop_when_thread_id_unchanged():
cmd = MagicMock()
cmd.name = "/evoskills"
_run(hook(ctx, agent, cmd))
await hook(ctx, agent, cmd)
assert state.thread_id == "same-tid"
def test_hook_skips_resume_warning_when_thread_unchanged():
async 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."""
@@ -261,13 +260,13 @@ def test_hook_skips_resume_warning_when_thread_unchanged():
cmd = MagicMock()
cmd.name = "/resume"
_run(hook(ctx, agent, cmd))
await hook(ctx, agent, cmd)
ctx.ui.append_system.assert_not_called()
ctx.ui.flush.assert_not_called()
def test_hook_emits_resume_warning_when_thread_changed():
async 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")
@@ -283,7 +282,7 @@ def test_hook_emits_resume_warning_when_thread_changed():
cmd = MagicMock()
cmd.name = "/resume"
_run(hook(ctx, agent, cmd))
await hook(ctx, agent, cmd)
ctx.ui.append_system.assert_called_once()
warn_text, warn_kwargs = (
@@ -296,7 +295,7 @@ def test_hook_emits_resume_warning_when_thread_changed():
ctx.ui.flush.assert_awaited_once()
def test_start_new_session_cb_rotates_thread_id():
async 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")
@@ -311,13 +310,13 @@ def test_start_new_session_cb_rotates_thread_id():
state,
runtime,
)
_run(cb())
await cb()
assert state.thread_id == "freshly-generated-tid"
assert runtime.thread_id == "freshly-generated-tid"
def test_start_new_session_cb_leaves_agent_alone():
async 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")
@@ -328,12 +327,12 @@ def test_start_new_session_cb_leaves_agent_alone():
)
cb = _make_serve_start_new_session_cb(state)
_run(cb())
await cb()
assert state.agent is agent
def test_serve_resume_callback_syncs_reloads_and_adopts_workspace():
async def test_serve_resume_callback_syncs_reloads_and_adopts_workspace():
cfg = _config()
old_agent = _agent("old-agent")
reloaded_agent = _agent("reloaded-agent")
@@ -364,7 +363,7 @@ def test_serve_resume_callback_syncs_reloads_and_adopts_workspace():
side_effect=_load_agent,
) as load_agent,
):
_run(cb("new-tid", "/new-ws"))
await 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)
@@ -376,7 +375,7 @@ def test_serve_resume_callback_syncs_reloads_and_adopts_workspace():
assert runtime.agent is reloaded_agent
def test_hook_emits_resume_warning_after_resume_callback_adopts_thread():
async def test_hook_emits_resume_warning_after_resume_callback_adopts_thread():
cfg = _config()
old_agent = _agent("old-agent")
reloaded_agent = _agent("reloaded-agent")
@@ -399,7 +398,7 @@ def test_hook_emits_resume_warning_after_resume_callback_adopts_thread():
return_value=reloaded_agent,
),
):
_run(cb("abc12345-resumed-tid", "/new-ws"))
await cb("abc12345-resumed-tid", "/new-ws")
hook = _make_serve_cmd_completed_hook(state, runtime, config=cfg)
ctx = MagicMock()
@@ -410,14 +409,14 @@ def test_hook_emits_resume_warning_after_resume_callback_adopts_thread():
cmd = MagicMock()
cmd.name = "/resume"
_run(hook(ctx, reloaded_agent, cmd))
await 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()
def test_serve_resume_callback_preserves_state_when_sync_fails():
async 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")
@@ -442,7 +441,7 @@ 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"),
):
_run(cb("new-tid", "/new-ws"))
await 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")
@@ -455,7 +454,7 @@ def test_serve_resume_callback_preserves_state_when_sync_fails():
assert runtime.thread_id == "old-tid"
def test_serve_resume_callback_load_failure_does_not_sync_or_adopt():
async def test_serve_resume_callback_load_failure_does_not_sync_or_adopt():
cfg = _config()
old_agent = _agent("old-agent")
state = _runtime_state(
@@ -479,7 +478,7 @@ def test_serve_resume_callback_load_failure_does_not_sync_or_adopt():
) as sync_server,
pytest.raises(RuntimeError, match="load failed"),
):
_run(cb("new-tid", "/new-ws"))
await 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")
@@ -493,7 +492,7 @@ def test_serve_resume_callback_load_failure_does_not_sync_or_adopt():
assert runtime.thread_id == "old-tid"
def test_hook_handles_both_agent_and_thread_swap():
async 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")
@@ -506,7 +505,7 @@ def test_hook_handles_both_agent_and_thread_swap():
ctx.thread_id = "new-tid"
cmd = MagicMock()
_run(hook(ctx, old_agent, cmd))
await hook(ctx, old_agent, cmd)
assert state.agent is new_agent
assert state.thread_id == "new-tid"
+222 -220
View File
File diff suppressed because it is too large Load Diff
+8 -9
View File
@@ -4,7 +4,6 @@ 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:
@@ -38,24 +37,24 @@ class TestSlackChannel:
assert channel.config is config
assert channel._running is False
def test_start_raises_without_bot_token(self):
async 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"):
_run(channel.start())
await channel.start()
def test_start_raises_without_app_token(self):
async 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"):
_run(channel.start())
await channel.start()
def test_stop_when_not_running(self):
async def test_stop_when_not_running(self):
config = SlackConfig(bot_token="xoxb-test", app_token="xapp-test")
channel = SlackChannel(config)
_run(channel.stop())
await channel.stop()
def test_send_returns_false_without_client(self):
async def test_send_returns_false_without_client(self):
from EvoScientist.channels.base import OutboundMessage
config = SlackConfig(bot_token="xoxb-test", app_token="xapp-test")
@@ -66,7 +65,7 @@ class TestSlackChannel:
content="hello",
metadata={"chat_id": "C123"},
)
result = _run(channel.send(msg))
result = await channel.send(msg)
assert result is False
+7 -12
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import asyncio
from datetime import datetime, timedelta
from typing import ClassVar
@@ -243,7 +242,7 @@ def test_build_status_text_uses_rich_styles():
assert text.spans
def test_build_session_status_snapshot_uses_fallback_window(monkeypatch):
async def test_build_session_status_snapshot_uses_fallback_window(monkeypatch):
class _FakeModel:
model_name: ClassVar[str] = "provider/demo-model"
profile: ClassVar[dict[str, object]] = {}
@@ -262,16 +261,12 @@ def test_build_session_status_snapshot_uses_fallback_window(monkeypatch):
_fake_count,
)
snapshot = asyncio.run(
build_session_status_snapshot(
"thread-1",
pending_user_text="pending",
graph_gateway=FakeGraphGateway(
thread_store=FakeThreadStore(
messages=[HumanMessage(content="existing")]
)
),
)
snapshot = await 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"
+103 -109
View File
@@ -20,7 +20,6 @@ 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,
@@ -144,10 +143,10 @@ class TestExtractToolContent:
class TestV3ProtocolStreaming:
"""Test stream_agent_events against v3 protocol events."""
def test_message_delta_emits_text(self):
async def test_message_delta_emits_text(self):
"""v3 content-block text deltas are processed."""
agent = FakeV3Agent([message_delta("hello world")])
events = collect_events(agent)
events = await 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"
@@ -156,7 +155,7 @@ class TestV3ProtocolStreaming:
assert "stream_mode" not in kwargs
assert "subgraphs" not in kwargs
def test_streamed_non_selector_json_is_replayed(self):
async def test_streamed_non_selector_json_is_replayed(self):
"""Normal JSON answers are not swallowed by selector JSON buffering."""
agent = FakeV3Agent(
[
@@ -165,13 +164,13 @@ class TestV3ProtocolStreaming:
message_delta(": 1}"),
]
)
events = collect_events(agent)
events = await 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}'
def test_incomplete_non_selector_json_flushes_on_message_finish(self):
async 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(
[
@@ -180,40 +179,42 @@ class TestV3ProtocolStreaming:
message_finish(),
]
)
events = collect_events(agent)
events = await 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":'
def test_json_answer_with_tools_key_is_replayed_without_selector_context(self):
async 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 = collect_events(agent)
events = await 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"}'
def test_text_delta_strips_legacy_thinking_tags(self):
async 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 = collect_events(agent)
events = await 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."
def test_text_delta_with_only_legacy_thinking_tags_is_skipped(self):
async def test_text_delta_with_only_legacy_thinking_tags_is_skipped(self):
agent = FakeV3Agent([message_delta("<thinking>just reasoning</thinking>")])
events = collect_events(agent)
events = await collect_events(agent)
assert [e for e in events if e.get("type") == "text"] == []
def test_updates_event_without_summary_is_skipped(self):
async def test_updates_event_without_summary_is_skipped(self):
"""Non-summary updates are skipped without error."""
agent = FakeV3Agent(
[
@@ -221,12 +222,14 @@ class TestV3ProtocolStreaming:
message_delta("should appear"),
]
)
events = collect_events(agent)
events = await 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"
def test_user_message_clears_completed_memory_activity_counts(self, monkeypatch):
async def test_user_message_clears_completed_memory_activity_counts(
self, monkeypatch
):
calls = []
monkeypatch.setattr(
"EvoScientist.stream.events.clear_completed_memory_activity_counts",
@@ -234,11 +237,13 @@ class TestV3ProtocolStreaming:
)
agent = FakeV3Agent([])
collect_events(agent, message="new user turn")
await collect_events(agent, message="new user turn")
assert calls == [True]
def test_command_message_clears_completed_memory_activity_counts(self, monkeypatch):
async def test_command_message_clears_completed_memory_activity_counts(
self, monkeypatch
):
calls = []
monkeypatch.setattr(
"EvoScientist.stream.events.clear_completed_memory_activity_counts",
@@ -247,12 +252,12 @@ class TestV3ProtocolStreaming:
agent = FakeV3Agent([])
resume_command = Command(resume={"decisions": [{"type": "approve"}]})
collect_events(agent, message=resume_command)
await collect_events(agent, message=resume_command)
assert calls == [True]
assert agent.astream_events.call_args.args[0] is resume_command
def test_summarization_filtered(self):
async def test_summarization_filtered(self):
"""v3 messages with lc_source=summarization emit summarization events."""
agent = FakeV3Agent(
[
@@ -260,7 +265,7 @@ class TestV3ProtocolStreaming:
message_delta("real content"),
]
)
events = collect_events(agent)
events = await collect_events(agent)
summary_start_events = [
e for e in events if e.get("type") == "summarization_start"
]
@@ -272,7 +277,7 @@ class TestV3ProtocolStreaming:
assert len(text_events) == 1
assert text_events[0]["content"] == "real content"
def test_updates_mode_summarization_event_emitted(self):
async 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",
@@ -294,7 +299,7 @@ class TestV3ProtocolStreaming:
message_delta("real content"),
]
)
events = collect_events(agent)
events = await collect_events(agent)
summary_start_events = [
e for e in events if e.get("type") == "summarization_start"
]
@@ -303,7 +308,7 @@ class TestV3ProtocolStreaming:
assert len(summary_events) == 1
assert summary_events[0]["content"] == "Key facts"
def test_updates_mode_does_not_duplicate_streamed_summarization(self):
async 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"
@@ -324,7 +329,7 @@ class TestV3ProtocolStreaming:
message_delta("real content"),
]
)
events = collect_events(agent)
events = await collect_events(agent)
summary_start_events = [
e for e in events if e.get("type") == "summarization_start"
]
@@ -333,7 +338,7 @@ class TestV3ProtocolStreaming:
assert len(summary_events) == 1
assert summary_events[0]["content"] == "synthetic summary"
def test_updates_mode_does_not_reemit_existing_summarization_event(self):
async 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",
@@ -352,7 +357,7 @@ class TestV3ProtocolStreaming:
],
state_values=summary_event,
)
events = collect_events(agent)
events = await collect_events(agent)
summary_start_events = [
e for e in events if e.get("type") == "summarization_start"
]
@@ -360,7 +365,7 @@ class TestV3ProtocolStreaming:
summary_events = [e for e in events if e.get("type") == "summarization"]
assert summary_events == []
def test_direct_stream_loads_existing_summarization_event_when_omitted(self):
async 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",
@@ -380,13 +385,9 @@ class TestV3ProtocolStreaming:
state_values=summary_event,
)
async def _collect():
events = []
async for event in stream_agent_events(agent, "hi", "t1"):
events.append(event)
return events
events = run_async(_collect())
events = []
async for event in stream_agent_events(agent, "hi", "t1"):
events.append(event)
summary_start_events = [
e for e in events if e.get("type") == "summarization_start"
@@ -395,19 +396,19 @@ class TestV3ProtocolStreaming:
summary_events = [e for e in events if e.get("type") == "summarization"]
assert summary_events == []
def test_whole_message_reasoning_is_not_duplicated(self):
async 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 = collect_events(agent)
events = await 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."
def test_tool_selector_reasoning_delta_is_suppressed(self):
async 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
@@ -432,7 +433,7 @@ class TestV3ProtocolStreaming:
)
]
)
events = collect_events(agent)
events = await collect_events(agent)
finally:
selector_mod._selector_active = original_active
@@ -441,7 +442,7 @@ class TestV3ProtocolStreaming:
for e in events
)
def test_tool_selector_whole_message_reasoning_is_suppressed(self):
async 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
@@ -453,7 +454,7 @@ class TestV3ProtocolStreaming:
content="",
)
agent = FakeV3Agent([protocol_event("messages", (message, {}))])
events = collect_events(agent)
events = await collect_events(agent)
finally:
selector_mod._selector_active = original_active
@@ -462,7 +463,7 @@ class TestV3ProtocolStreaming:
for e in events
)
def test_tool_events_emit_call_and_result(self):
async def test_tool_events_emit_call_and_result(self):
"""v3 tool projection events become UI tool call/result events."""
output = ToolMessage(
name="read_file",
@@ -475,7 +476,7 @@ class TestV3ProtocolStreaming:
tool_finished(output),
]
)
events = collect_events(agent)
events = await 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"
@@ -489,7 +490,7 @@ class TestV3ProtocolStreaming:
@pytest.mark.filterwarnings(
"ignore:The v3 streaming protocol on Pregel is experimental"
)
def test_live_deepagents_v3_tool_result_preserves_tool_call_id(self):
async 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
@@ -518,17 +519,14 @@ class TestV3ProtocolStreaming:
system_prompt="Use tools when requested.",
)
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())
events = [
event
async for event in stream_agent_events(
agent,
"run probe",
"live-deepagents-tool-id",
)
]
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")
@@ -551,7 +549,7 @@ class TestV3ProtocolStreaming:
@pytest.mark.filterwarnings(
"ignore:The v3 streaming protocol on Pregel is experimental"
)
def test_live_deepagents_v3_hitl_emits_tool_call_and_single_interrupt(self):
async 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
@@ -581,17 +579,14 @@ class TestV3ProtocolStreaming:
checkpointer=InMemorySaver(),
)
async def _collect_events():
return [
event
async for event in stream_agent_events(
agent,
"run echo",
"live-deepagents-hitl",
)
]
events = run_async(_collect_events())
events = [
event
async for event in stream_agent_events(
agent,
"run echo",
"live-deepagents-hitl",
)
]
tool_calls = [e for e in events if e.get("type") == "tool_call"]
interrupts = [e for e in events if e.get("type") == "interrupt"]
@@ -611,7 +606,7 @@ class TestV3ProtocolStreaming:
@pytest.mark.filterwarnings(
"ignore:The v3 streaming protocol on Pregel is experimental"
)
def test_live_deepagents_v3_ask_user_suppresses_interrupt_tool_result(self):
async 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(
@@ -654,15 +649,15 @@ class TestV3ProtocolStreaming:
)
]
first_events = run_async(_collect("ask"))
first_events = await _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 = run_async(
_collect(Command(resume={"answers": ["CIFAR-10"], "status": "answered"}))
resumed_events = await _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 == {
@@ -678,7 +673,9 @@ class TestV3ProtocolStreaming:
@pytest.mark.filterwarnings(
"ignore:The v3 streaming protocol on Pregel is experimental"
)
def test_live_deepagents_v3_task_result_uses_subagent_tool_message_content(self):
async 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(
@@ -717,17 +714,14 @@ class TestV3ProtocolStreaming:
],
)
async def _collect_events():
return [
event
async for event in stream_agent_events(
agent,
"delegate",
"live-deepagents-subagent",
)
]
events = run_async(_collect_events())
events = [
event
async for event in stream_agent_events(
agent,
"delegate",
"live-deepagents-subagent",
)
]
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")
@@ -745,7 +739,7 @@ class TestV3ProtocolStreaming:
assert task_result["content"] == "subagent final"
assert "Command(" not in task_result["content"]
def test_message_tool_call_block_emits_pre_execution_tool_call(self):
async def test_message_tool_call_block_emits_pre_execution_tool_call(self):
"""Model-declared tool calls remain visible before execution starts."""
agent = FakeV3Agent(
[
@@ -776,14 +770,14 @@ class TestV3ProtocolStreaming:
),
]
)
events = collect_events(agent)
events = await 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"}
def test_tool_selection_flushes_before_tool_only_step(self):
async 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
@@ -806,7 +800,7 @@ class TestV3ProtocolStreaming:
tool_finished(output),
]
)
events = collect_events(agent)
events = await collect_events(agent)
finally:
selector_mod._current_selected_tools = original_selected
selector_mod._total_tools_count = original_total
@@ -817,7 +811,7 @@ class TestV3ProtocolStreaming:
selection = next(e for e in events if e.get("type") == "tool_selection")
assert selection["tools"] == ["read_file"]
def test_subagent_projection_routes_namespaced_events(self):
async def test_subagent_projection_routes_namespaced_events(self):
"""DeepAgents subagent projection supplies identity for namespaced events."""
namespace = ("task", "abc")
output = ToolMessage(
@@ -838,7 +832,7 @@ class TestV3ProtocolStreaming:
],
subagents=[FakeSubagent(namespace, "research-agent")],
)
events = collect_events(agent)
events = await 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)
@@ -865,7 +859,7 @@ class TestV3ProtocolStreaming:
)
assert event_types.index("subagent_end") < event_types.index("done")
def test_namespaced_events_wait_for_delayed_subagent_registration(self):
async def test_namespaced_events_wait_for_delayed_subagent_registration(self):
"""Subagent events are not dropped if protocol events arrive first."""
namespace = ("task", "late")
@@ -900,7 +894,7 @@ class TestV3ProtocolStreaming:
return Snapshot()
events = collect_events(Agent())
events = await collect_events(Agent())
event_types = [e["type"] for e in events]
text = next(e for e in events if e.get("type") == "subagent_text")
@@ -908,7 +902,7 @@ class TestV3ProtocolStreaming:
assert text["instance_id"] == "task:late"
assert event_types.index("subagent_start") < event_types.index("subagent_text")
def test_subagent_tool_dedupe_uses_resolved_path(self):
async 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")
@@ -936,7 +930,7 @@ class TestV3ProtocolStreaming:
],
subagents=[FakeSubagent(subagent_path, "research-agent")],
)
events = collect_events(agent)
events = await 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"]
@@ -948,7 +942,7 @@ class TestV3ProtocolStreaming:
assert results[0]["instance_id"] == "task:abc"
assert results[0]["id"] == "sa-tc"
def test_subagent_end_is_emitted_before_later_root_text(self):
async 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()
@@ -996,19 +990,19 @@ class TestV3ProtocolStreaming:
return Snapshot()
events = collect_events(Agent())
events = await collect_events(Agent())
event_types = [e["type"] for e in events]
assert event_types.index("subagent_end") < event_types.index("text")
def test_subagent_projection_is_subscribed_before_protocol_pump(self):
async 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 = collect_events(agent)
events = await 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"] == []
@@ -1018,7 +1012,7 @@ class TestV3ProtocolStreaming:
assert text["content"] == "Sub-agent finding."
assert text["instance_id"] == "task:early"
def test_parallel_same_name_subagent_events_carry_instance_ids(self):
async 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")
@@ -1048,7 +1042,7 @@ class TestV3ProtocolStreaming:
FakeSubagent(ns2, "research-agent"),
],
)
events = collect_events(agent)
events = await 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"]
@@ -1060,7 +1054,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"}
def test_stream_construction_error_emits_error_before_reraising(self):
async def test_stream_construction_error_emits_error_before_reraising(self):
"""astream_events construction failures preserve the UI error event contract."""
events = []
@@ -1073,10 +1067,10 @@ class TestV3ProtocolStreaming:
events.append(ev)
with pytest.raises(RuntimeError, match="boom"):
run_async(collect())
await collect()
assert events == [{"type": "error", "message": "boom"}]
def test_generator_close_aborts_underlying_v3_stream(self):
async 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():
@@ -1090,7 +1084,7 @@ class TestV3ProtocolStreaming:
await stream.aclose()
return first, agent.aborted
first, aborted = run_async(consume_one_and_close())
first, aborted = await consume_one_and_close()
assert first["type"] == "text"
assert first["content"] == "hi"
assert aborted is True
@@ -1099,7 +1093,7 @@ class TestV3ProtocolStreaming:
class TestUsageStatsExtraction:
"""Test token usage extraction from v3 message-finish events."""
def test_usage_metadata_emitted(self):
async def test_usage_metadata_emitted(self):
"""v3 message-finish usage emits usage_stats event."""
agent = FakeV3Agent(
[
@@ -1113,16 +1107,16 @@ class TestUsageStatsExtraction:
),
]
)
events = collect_events(agent)
events = await 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
def test_no_usage_metadata_no_event(self):
async 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 = collect_events(agent)
events = await collect_events(agent)
usage_events = [e for e in events if e.get("type") == "usage_stats"]
assert len(usage_events) == 0
@@ -1158,7 +1152,7 @@ class TestSummarizationHelpers:
assert isinstance(summary_message, HumanMessage)
assert summary_message.content == "Summary body"
def test_zero_tokens_not_emitted(self):
async def test_zero_tokens_not_emitted(self):
"""Zero input and output tokens should not emit usage_stats."""
agent = FakeV3Agent(
[
@@ -1168,6 +1162,6 @@ class TestSummarizationHelpers:
),
]
)
events = collect_events(agent)
events = await collect_events(agent)
usage_events = [e for e in events if e.get("type") == "usage_stats"]
assert len(usage_events) == 0
+4 -5
View File
@@ -16,7 +16,6 @@ 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):
@@ -58,7 +57,7 @@ def _interrupting_app():
return g.compile(checkpointer=InMemorySaver())
def test_recovery_clears_stuck_state_after_crash():
async def test_recovery_clears_stuck_state_after_crash():
app = _crashing_app()
cfg = {"configurable": {"thread_id": "t1"}}
try:
@@ -68,7 +67,7 @@ def test_recovery_clears_stuck_state_after_crash():
# The crash left the graph frozen at node 'b'.
assert app.get_state(cfg).next == ("b",)
_run(_clear_interrupted_graph_state(app, cfg))
await _clear_interrupted_graph_state(app, cfg)
snap = app.get_state(cfg)
assert snap.next == () # stuck state actually cleared
@@ -79,7 +78,7 @@ def test_recovery_clears_stuck_state_after_crash():
assert app.invoke({"x": 41}, cfg)["x"] == 142
def test_recovery_preserves_pending_hitl_interrupt():
async def test_recovery_preserves_pending_hitl_interrupt():
app = _interrupting_app()
cfg = {"configurable": {"thread_id": "t1"}}
app.invoke({"x": 0}, cfg) # parks at interrupt()
@@ -87,7 +86,7 @@ def test_recovery_preserves_pending_hitl_interrupt():
assert before.next == ("ask",)
assert before.interrupts
_run(_clear_interrupted_graph_state(app, cfg))
await _clear_interrupted_graph_state(app, cfg)
after = app.get_state(cfg)
assert after.next == ("ask",) # interrupt left intact, still resumable
+31 -42
View File
@@ -12,7 +12,6 @@ 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 ─────────────────────────────────────────────────────
@@ -50,8 +49,8 @@ def test_stt_models_keys():
# ── transcribe_file: non-audio path ──────────────────────────────────
def test_transcribe_non_audio_returns_none():
result = run_async(transcribe_file("photo.jpg", language="auto"))
async def test_transcribe_non_audio_returns_none():
result = await transcribe_file("photo.jpg", language="auto")
assert result is None
@@ -78,37 +77,37 @@ def _patch_whisper(whisper_model):
)
def test_transcribe_en_uses_whisper():
async 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 = run_async(transcribe_file("voice.mp3", language="en"))
result = await transcribe_file("voice.mp3", language="en")
stt_mod._engine = None
assert result == "Hello world"
def test_transcribe_auto_uses_whisper():
async 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 = run_async(transcribe_file("voice.ogg", language="auto"))
result = await transcribe_file("voice.ogg", language="auto")
stt_mod._engine = None
assert result == "Bonjour monde"
def test_transcribe_zh_uses_whisper():
async def test_transcribe_zh_uses_whisper():
import EvoScientist.stt as stt_mod
stt_mod._engine = None
with _patch_whisper(_make_whisper_mock("你好世界")):
result = run_async(transcribe_file("voice.ogg", language="zh"))
result = await transcribe_file("voice.ogg", language="zh")
stt_mod._engine = None
assert result == "你好世界"
def test_transcribe_custom_model_override():
async def test_transcribe_custom_model_override():
"""stt_model config overrides the default model mapping."""
import EvoScientist.stt as stt_mod
@@ -121,10 +120,8 @@ def test_transcribe_custom_model_override():
self._model = _make_whisper_mock("test")
with patch.object(stt_mod._WhisperEngine, "__init__", patched_init):
run_async(
transcribe_file(
"voice.ogg", language="auto", model="openai/whisper-large-v3"
)
await transcribe_file(
"voice.ogg", language="auto", model="openai/whisper-large-v3"
)
stt_mod._engine = None
assert captured_model_id == ["openai/whisper-large-v3"]
@@ -133,7 +130,7 @@ def test_transcribe_custom_model_override():
# ── transcribe_file: missing dependency ──────────────────────────────
def test_transcribe_missing_dep_returns_none():
async def test_transcribe_missing_dep_returns_none():
import sys
import EvoScientist.stt as stt_mod
@@ -142,7 +139,7 @@ def test_transcribe_missing_dep_returns_none():
saved = sys.modules.pop("faster_whisper", None)
try:
with patch.dict("sys.modules", {"faster_whisper": None}):
result = run_async(transcribe_file("voice.mp3", language="auto"))
result = await transcribe_file("voice.mp3", language="auto")
finally:
if saved is not None:
sys.modules["faster_whisper"] = saved
@@ -169,7 +166,7 @@ def _make_channel():
return ch, captured
def test_enqueue_raw_stt_prepends_transcript():
async def test_enqueue_raw_stt_prepends_transcript():
"""_enqueue_raw prepends STT transcript to raw.text when stt_enabled."""
from EvoScientist.channels.base import RawIncoming
@@ -189,22 +186,18 @@ def test_enqueue_raw_stt_prepends_transcript():
timestamp=datetime.now(),
)
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)
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 == []
def test_enqueue_raw_stt_disabled_skips_transcription():
async def test_enqueue_raw_stt_disabled_skips_transcription():
"""When stt_enabled=False, transcription is not called."""
from EvoScientist.channels.base import RawIncoming
@@ -221,16 +214,14 @@ def test_enqueue_raw_stt_disabled_skips_transcription():
mock_transcribe = AsyncMock()
async def _run():
with patch("EvoScientist.stt.transcribe_file", mock_transcribe):
await ch._enqueue_raw(raw)
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 == ""
def test_enqueue_raw_stt_appends_to_existing_text():
async def test_enqueue_raw_stt_appends_to_existing_text():
"""Transcript is prepended before any existing caption text."""
from EvoScientist.channels.base import RawIncoming
@@ -249,17 +240,15 @@ def test_enqueue_raw_stt_appends_to_existing_text():
timestamp=datetime.now(),
)
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)
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"
+144 -172
View File
@@ -17,7 +17,6 @@ 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 (
@@ -82,7 +81,7 @@ class TestSubagentTextEmitter:
class TestStreamAgentEventsSubagentText:
"""Verify sub-agent text chunks yield subagent_text events."""
def test_subagent_text_emitted_for_subagent_chunks(self):
async 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(
@@ -93,23 +92,23 @@ class TestStreamAgentEventsSubagentText:
],
subagents=[FakeSubagent(namespace, "research-agent")],
)
events = collect_events(agent)
events = await 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"
def test_subagent_text_not_emitted_for_main_agent(self):
async 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 = collect_events(agent)
events = await 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
def test_multiple_subagent_text_chunks_all_emitted(self):
async 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(
@@ -120,7 +119,7 @@ class TestStreamAgentEventsSubagentText:
],
subagents=[FakeSubagent(namespace, "research-agent")],
)
events = collect_events(agent)
events = await 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)
@@ -132,7 +131,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)
def test_parallel_same_name_agents_get_distinct_instance_ids(self):
async 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.
@@ -154,7 +153,7 @@ class TestStreamAgentEventsSubagentText:
FakeSubagent(ns2, "research-agent"),
],
)
events = collect_events(agent)
events = await collect_events(agent)
sa_text = [e for e in events if e.get("type") == "subagent_text"]
assert len(sa_text) == 3
@@ -211,7 +210,7 @@ def _make_consumer(stream_events: list[dict], **kw):
class TestConsumerSubagentTextFallback:
"""InboundConsumer should use sub-agent text as fallback when main agent is silent."""
def test_subagent_text_used_when_no_final_content(self):
async def test_subagent_text_used_when_no_final_content(self):
"""When the main agent produces no text, sub-agent text becomes the response."""
events = [
{
@@ -230,27 +229,24 @@ class TestConsumerSubagentTextFallback:
]
consumer, bus = _make_consumer(events)
async def _test():
msg = BusInbound(
channel="stub",
sender_id="u1",
chat_id="c1",
content="analyze papers",
)
await bus.publish_inbound(msg)
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
_run(_test())
def test_final_content_takes_priority_over_subagent_text(self):
async def test_final_content_takes_priority_over_subagent_text(self):
"""When the main agent produces text, sub-agent text is ignored."""
events = [
{
@@ -264,26 +260,23 @@ class TestConsumerSubagentTextFallback:
]
consumer, bus = _make_consumer(events)
async def _test():
msg = BusInbound(
channel="stub",
sender_id="u1",
chat_id="c1",
content="test",
)
await bus.publish_inbound(msg)
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
_run(_test())
def test_duplicate_thinking_not_relayed_across_resume_rounds(self):
async 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)
@@ -329,30 +322,27 @@ class TestConsumerSubagentTextFallback:
return_value={"answers": ["yes"], "status": "answered"}
)
async def _test():
await bus.publish_inbound(
BusInbound(
channel="stub",
sender_id="u1",
chat_id="c1",
content="analyze papers",
)
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
_run(_test())
def test_new_thinking_relayed_after_resume(self):
async def test_new_thinking_relayed_after_resume(self):
"""Genuinely different thinking in round 2 should be sent."""
bus = MessageBus()
mgr = ChannelManager(bus)
@@ -399,58 +389,52 @@ class TestConsumerSubagentTextFallback:
return_value={"answers": ["yes"], "status": "answered"}
)
async def _test():
await bus.publish_inbound(
BusInbound(
channel="stub",
sender_id="u1",
chat_id="c1",
content="analyze papers",
)
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
_run(_test())
def test_no_response_fallback_when_both_empty(self):
async 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)
async def _test():
msg = BusInbound(
channel="stub",
sender_id="u1",
chat_id="c1",
content="test",
)
await bus.publish_inbound(msg)
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
_run(_test())
def test_done_content_overrides_subagent_text(self):
async def test_done_content_overrides_subagent_text(self):
"""Done event with content takes priority over sub-agent text buffer."""
events = [
{
@@ -463,24 +447,21 @@ class TestConsumerSubagentTextFallback:
]
consumer, bus = _make_consumer(events)
async def _test():
msg = BusInbound(
channel="stub",
sender_id="u1",
chat_id="c1",
content="test",
)
await bus.publish_inbound(msg)
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
_run(_test())
await consumer.stop()
await task
# ═══════════════════════════════════════════════════════════════════
@@ -560,7 +541,7 @@ class TestJoinSubagentText:
class TestConsumerParallelSubagentFallback:
"""Consumer should group parallel sub-agent text by agent name."""
def test_parallel_agents_grouped_with_attribution(self):
async def test_parallel_agents_grouped_with_attribution(self):
"""Multiple sub-agents produce grouped, attributed output."""
events = [
{
@@ -585,27 +566,24 @@ class TestConsumerParallelSubagentFallback:
]
consumer, bus = _make_consumer(events)
async def _test():
msg = BusInbound(
channel="stub",
sender_id="u1",
chat_id="c1",
content="test",
)
await bus.publish_inbound(msg)
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
_run(_test())
def test_single_agent_no_attribution_prefix(self):
async def test_single_agent_no_attribution_prefix(self):
"""Single sub-agent fallback has no [name]: prefix."""
events = [
{
@@ -618,31 +596,28 @@ class TestConsumerParallelSubagentFallback:
]
consumer, bus = _make_consumer(events)
async def _test():
msg = BusInbound(
channel="stub",
sender_id="u1",
chat_id="c1",
content="test",
)
await bus.publish_inbound(msg)
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
_run(_test())
await consumer.stop()
await task
class TestConsumerSameNameInterleaved:
"""Two instances of the same agent type with interleaved chunks."""
def test_same_name_interleaved_chunks_separated_by_instance_id(self):
async 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
@@ -678,32 +653,29 @@ class TestConsumerSameNameInterleaved:
]
consumer, bus = _make_consumer(events)
async def _test():
msg = BusInbound(
channel="stub",
sender_id="u1",
chat_id="c1",
content="test",
)
await bus.publish_inbound(msg)
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
_run(_test())
await consumer.stop()
await task
class TestDelegationPromptSummarize:
+6 -7
View File
@@ -4,7 +4,6 @@ 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:
@@ -32,18 +31,18 @@ class TestTelegramChannel:
assert channel.config is config
assert channel._running is False
def test_start_raises_without_token(self):
async def test_start_raises_without_token(self):
config = TelegramConfig(bot_token="")
channel = TelegramChannel(config)
with pytest.raises(ChannelError, match="bot token"):
_run(channel.start())
await channel.start()
def test_stop_when_not_running(self):
async def test_stop_when_not_running(self):
config = TelegramConfig(bot_token="test")
channel = TelegramChannel(config)
_run(channel.stop())
await channel.stop()
def test_send_returns_false_without_app(self):
async def test_send_returns_false_without_app(self):
from EvoScientist.channels.base import OutboundMessage
config = TelegramConfig(bot_token="test")
@@ -54,5 +53,5 @@ class TestTelegramChannel:
content="hello",
metadata={"chat_id": "123"},
)
result = _run(channel.send(msg))
result = await channel.send(msg)
assert result is False
+8 -9
View File
@@ -4,7 +4,6 @@ from unittest.mock import MagicMock
from rich.table import Table
from tests.conftest import run_async as _run
from tests.fakes import FakeGraphGateway, FakeThreadStore
@@ -24,15 +23,15 @@ def _ctx(**overrides):
class TestThreadsCommand:
def test_empty_list_prints_message(self):
async def test_empty_list_prints_message(self):
from EvoScientist.commands.implementation.session import ThreadsCommand
ctx, ui = _ctx()
_run(ThreadsCommand().execute(ctx, []))
await ThreadsCommand().execute(ctx, [])
ui.append_system.assert_called_once()
assert "No saved sessions" in ui.append_system.call_args.args[0]
def test_renders_table_with_current_marker(self):
async def test_renders_table_with_current_marker(self):
from EvoScientist.commands.implementation.session import ThreadsCommand
ctx, ui = _ctx(thread_id="current")
@@ -54,7 +53,7 @@ class TestThreadsCommand:
]
store = FakeThreadStore(threads=threads)
ctx.graph_gateway = FakeGraphGateway(thread_store=store)
_run(ThreadsCommand().execute(ctx, []))
await ThreadsCommand().execute(ctx, [])
ui.mount_renderable.assert_called_once()
table = ui.mount_renderable.call_args.args[0]
assert isinstance(table, Table)
@@ -64,7 +63,7 @@ class TestThreadsCommand:
assert "/delete" in footer
assert "/new" in footer
def test_footer_hint_suppressed_in_channel_mode(self):
async 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
@@ -80,10 +79,10 @@ class TestThreadsCommand:
]
store = FakeThreadStore(threads=threads)
ctx.graph_gateway = FakeGraphGateway(thread_store=store)
_run(ThreadsCommand().execute(ctx, []))
await ThreadsCommand().execute(ctx, [])
ui.append_system.assert_not_called()
def test_channel_mode_drops_model_column(self):
async def test_channel_mode_drops_model_column(self):
"""Non-interactive (channel) UIs get a narrower table."""
from EvoScientist.commands.implementation.session import ThreadsCommand
@@ -99,7 +98,7 @@ class TestThreadsCommand:
]
store = FakeThreadStore(threads=threads)
ctx.graph_gateway = FakeGraphGateway(thread_store=store)
_run(ThreadsCommand().execute(ctx, []))
await 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]
+10 -16
View File
@@ -149,40 +149,34 @@ class TestWrapToolCallAsync:
def setup_method(self):
self.mw = ToolErrorHandlerMiddleware()
@staticmethod
def _run(coro):
from tests.conftest import run_async
return run_async(coro)
def test_success_passes_through(self):
async 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 = self._run(self.mw.awrap_tool_call(req, handler))
result = await self.mw.awrap_tool_call(req, handler)
assert result is expected
def test_command_passes_through(self):
async def test_command_passes_through(self):
cmd = Command(update={"messages": []})
async def handler(req):
return cmd
req = _make_request()
result = self._run(self.mw.awrap_tool_call(req, handler))
result = await self.mw.awrap_tool_call(req, handler)
assert result is cmd
def test_exception_returns_error_tool_message(self):
async 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 = self._run(self.mw.awrap_tool_call(req, handler))
result = await self.mw.awrap_tool_call(req, handler)
assert isinstance(result, ToolMessage)
assert result.status == "error"
@@ -190,21 +184,21 @@ class TestWrapToolCallAsync:
assert result.name == "slow_tool"
assert "MCP server timed out" in result.content
def test_exception_does_not_propagate(self):
async def test_exception_does_not_propagate(self):
async def handler(req):
raise ConnectionError("connection lost")
req = _make_request()
result = self._run(self.mw.awrap_tool_call(req, handler))
result = await self.mw.awrap_tool_call(req, handler)
assert isinstance(result, ToolMessage)
def test_keyboard_interrupt_propagates(self):
async def test_keyboard_interrupt_propagates(self):
async def handler(req):
raise KeyboardInterrupt()
req = _make_request()
with pytest.raises(KeyboardInterrupt):
self._run(self.mw.awrap_tool_call(req, handler))
await self.mw.awrap_tool_call(req, handler)
# ---------------------------------------------------------------------------
+162 -190
View File
@@ -117,8 +117,8 @@ def _capture_app(monkeypatch) -> object:
# ---------------------------------------------------------------------------
def test_clear_chat_resets_scroll_after_long_anchored_conversation(
monkeypatch, run_async
async def test_clear_chat_resets_scroll_after_long_anchored_conversation(
monkeypatch,
):
"""Repro of issue #301: clear after a long anchored stream → banner on top.
@@ -128,104 +128,91 @@ def test_clear_chat_resets_scroll_after_long_anchored_conversation(
the now-empty bottom of the previous content.
"""
async def scenario():
from textual.containers import VerticalScroll
from textual.widgets import Static
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
run_async(scenario())
_assert_banner_at_top(chat, welcome, label="after /new on long anchored convo")
assert len(chat.children) == 2
def test_clear_chat_with_anchor_released_also_resets(monkeypatch, run_async):
async def test_clear_chat_with_anchor_released_also_resets(monkeypatch):
"""User scrolled up (anchor released) before /new → still lands at top."""
async def scenario():
from textual.containers import VerticalScroll
from textual.widgets import Static
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
run_async(scenario())
_assert_banner_at_top(chat, welcome, label="after /new with released anchor")
assert len(chat.children) == 2
def test_clear_chat_short_conversation_anchored(monkeypatch, run_async):
async def test_clear_chat_short_conversation_anchored(monkeypatch):
"""Even with a short conversation, anchor + clear should not push banner down."""
async def scenario():
from textual.containers import VerticalScroll
from textual.widgets import Static
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")
run_async(scenario())
_assert_banner_at_top(chat, welcome, label="after /new on short convo")
def test_clear_chat_then_full_user_turn_keeps_banner_at_top(monkeypatch, run_async):
async def test_clear_chat_then_full_user_turn_keeps_banner_at_top(monkeypatch):
"""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.
@@ -237,69 +224,64 @@ def test_clear_chat_then_full_user_turn_keeps_banner_at_top(monkeypatch, run_asy
wipe even when more widgets are mounted afterwards.
"""
async def scenario():
from textual.containers import VerticalScroll
from textual.widgets import Static
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})"
),
)
run_async(scenario())
_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})"
),
)
def test_short_turn_keeps_banner_at_top_after_layout_refresh(monkeypatch, run_async):
async def test_short_turn_keeps_banner_at_top_after_layout_refresh(monkeypatch):
"""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.
@@ -313,83 +295,73 @@ def test_short_turn_keeps_banner_at_top_after_layout_refresh(monkeypatch, run_as
overflows (``max_scroll_y > 0``).
"""
async def scenario():
from textual.containers import VerticalScroll
from textual.widgets import Static
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"
)
run_async(scenario())
_assert_banner_at_top(chat, welcome, label="after short turn + trailing mount")
def test_long_turn_keeps_viewport_pinned_to_bottom(monkeypatch, run_async):
async def test_long_turn_keeps_viewport_pinned_to_bottom(monkeypatch):
"""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.
"""
async def scenario():
from textual.containers import VerticalScroll
from textual.widgets import Static
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"
run_async(scenario())
# 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"
# ---------------------------------------------------------------------------
+8 -7
View File
@@ -5,7 +5,6 @@ from types import SimpleNamespace
import pytest
from EvoScientist.commands.base import ChannelRuntime, CommandContext
from tests.conftest import run_async as _run
pytest.importorskip("textual")
@@ -43,7 +42,7 @@ class _StubApp:
self.refresh_calls.append(reset_streaming_text)
def test_sync_tui_command_completion_adopts_agent_swap(monkeypatch):
async 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
@@ -63,7 +62,7 @@ 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")
_run(tui_mod._sync_tui_command_completion(app, ctx, "old-agent", cmd))
await 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")]
@@ -72,7 +71,7 @@ def test_sync_tui_command_completion_adopts_agent_swap(monkeypatch):
assert app._channel_runtime.thread_id == "thread-1"
def test_sync_tui_command_completion_refreshes_without_agent_swap(monkeypatch):
async def test_sync_tui_command_completion_refreshes_without_agent_swap(monkeypatch):
import EvoScientist.cli.tui_interactive as tui_mod
app = _StubApp()
@@ -85,14 +84,16 @@ def test_sync_tui_command_completion_refreshes_without_agent_swap(monkeypatch):
monkeypatch.setattr(tui_mod, "_channels_is_running", lambda: False)
_run(tui_mod._sync_tui_command_completion(app, ctx, "same-agent", cmd))
await 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]
def test_sync_tui_rebinds_runtime_on_thread_rotation_without_agent_swap(monkeypatch):
async 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."""
@@ -111,7 +112,7 @@ def test_sync_tui_rebinds_runtime_on_thread_rotation_without_agent_swap(monkeypa
monkeypatch.setattr(tui_mod, "_channels_is_running", lambda: True)
_run(tui_mod._sync_tui_command_completion(app, ctx, "same-agent", cmd))
await 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,14 +10,16 @@ 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
@unittest.skipUnless(_has_textual, "textual not installed")
class TestLoadingWidget(unittest.TestCase):
@pytest.mark.skipif(not _has_textual, reason="textual not installed")
class TestLoadingWidget:
"""LoadingWidget construction and attributes."""
def test_construction(self):
@@ -43,7 +45,7 @@ class TestLoadingWidget(unittest.TestCase):
assert w._frame == 1
assert w._elapsed == 0.1
def test_cleanup_stops_timer_and_removes(self):
async def test_cleanup_stops_timer_and_removes(self):
from EvoScientist.cli.widgets.loading_widget import LoadingWidget
class _Timer:
@@ -58,9 +60,7 @@ class TestLoadingWidget(unittest.TestCase):
w._timer_handle = timer
w.remove = AsyncMock()
from tests.conftest import run_async
run_async(w.cleanup())
await w.cleanup()
assert timer.stopped is True
assert w._timer_handle is None
+109 -131
View File
@@ -20,7 +20,6 @@ from EvoScientist.channels.wechat.crypto import (
_pkcs7_unpad,
parse_xml,
)
from tests.conftest import run_async as _run
# ── Config tests ──────────────────────────────────────────────────
@@ -83,36 +82,36 @@ class TestWeChatChannelInit:
channel = WeChatChannel(config, backend="wechatmp")
assert channel._backend == "wechatmp"
def test_start_raises_without_corp_id(self):
async 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"):
_run(channel.start())
await channel.start()
def test_start_raises_without_secret(self):
async 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"):
_run(channel.start())
await channel.start()
def test_start_raises_without_agent_id(self):
async 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"):
_run(channel.start())
await channel.start()
def test_start_raises_mp_without_app_id(self):
async 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"):
_run(channel.start())
await channel.start()
def test_stop_when_not_running(self):
async def test_stop_when_not_running(self):
config = WeComConfig(corp_id="c", agent_id="1", secret="s")
channel = WeChatChannel(config, backend="wecom")
_run(channel.stop()) # Should not raise
await channel.stop() # Should not raise
def test_send_returns_false_without_client(self):
async def test_send_returns_false_without_client(self):
from EvoScientist.channels.base import OutboundMessage
config = WeComConfig(corp_id="c", agent_id="1", secret="s")
@@ -123,7 +122,7 @@ class TestWeChatChannelInit:
content="hello",
metadata={"chat_id": "user1"},
)
result = _run(channel.send(msg))
result = await channel.send(msg)
assert result is False
@@ -324,144 +323,123 @@ class TestMessageProcessing:
)
return WeChatChannel(config, backend="wecom")
def test_text_message_queued(self):
async def test_text_message_queued(self):
channel = self._make_channel()
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"
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"
_run(_test())
def test_location_message(self):
async def test_location_message(self):
channel = self._make_channel()
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
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
_run(_test())
def test_voice_recognition(self):
async def test_voice_recognition(self):
channel = self._make_channel()
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
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
_run(_test())
def test_link_message(self):
async def test_link_message(self):
channel = self._make_channel()
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
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
_run(_test())
def test_subscribe_event(self):
async def test_subscribe_event(self):
channel = self._make_channel()
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
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
_run(_test())
def test_unsubscribe_ignored(self):
async def test_unsubscribe_ignored(self):
channel = self._make_channel()
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()
await channel._process_message(
{
"MsgType": "event",
"Event": "unsubscribe",
"FromUserName": "user1",
"ToUserName": "bot",
"MsgId": "",
"CreateTime": str(int(time.time())),
}
)
assert channel._queue.empty()
_run(_test())
def test_empty_message_ignored(self):
async def test_empty_message_ignored(self):
channel = self._make_channel()
async def _test():
await channel._process_message(
{
"MsgType": "text",
"Content": "",
"FromUserName": "",
"ToUserName": "bot",
}
)
assert channel._queue.empty()
_run(_test())
await channel._process_message(
{
"MsgType": "text",
"Content": "",
"FromUserName": "",
"ToUserName": "bot",
}
)
assert channel._queue.empty()
# ── Registration test ─────────────────────────────────────────────
Generated
+20 -3
View File
@@ -992,6 +992,7 @@ dev = [
{ name = "build" },
{ name = "pre-commit" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "pytest-timeout" },
{ name = "ruff" },
@@ -1032,6 +1033,7 @@ dev = [
{ name = "build" },
{ name = "pre-commit" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "pytest-timeout" },
{ name = "ruff" },
@@ -1075,6 +1077,7 @@ 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" },
@@ -1103,6 +1106,7 @@ 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" },
@@ -3503,6 +3507,19 @@ 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"
@@ -4178,11 +4195,11 @@ wheels = [
[[package]]
name = "soupsieve"
version = "2.8.3"
version = "2.8.4"
source = { registry = "https://pypi.org/simple" }
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]